From e9712d19cb38d440abe352f7667750d92af41471 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Mon, 24 Aug 2026 18:00:50 +0700 Subject: [PATCH] fix(tui): pick keys, accounts, and models from the CLI Settings Enter was still in the input queue, so the next picker confirmed the current row and switching looked like a no-op. Drain leftover keys, list API keys newest-first (login prefers the latest), and open account/key/model pickers in the launcher. Add section padding so the config and palette screens breathe. Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 235 +++++++++++++++++++++++++++++++---------------- src/http.rs | 51 ++++++++++ src/tui/keys.rs | 8 +- src/tui/live.rs | 11 +++ src/tui/state.rs | 9 +- src/tui/view.rs | 150 ++++++++++++++++++++++-------- tests/cli.rs | 16 ++++ 7 files changed, 354 insertions(+), 126 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index b68a794..c25c08c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -614,6 +614,7 @@ fn persist_login( .and_then(|c| c.profiles.get(&c.active_profile)); let base = resolve_base_url(&parsed.flags, stored); validate_key(&base, key)?; + let key = resolve_latest_key(&base, key); let name = get_string_flag(&parsed.flags, "profile").unwrap_or_else(|| { existing .as_ref() @@ -622,7 +623,7 @@ fn persist_login( }); let timeout = get_string_flag(&parsed.flags, "timeout").and_then(|s| s.parse().ok()); let mut profile = create_default_profile(DefaultProfileInput { - api_key: Some(key.to_string()), + api_key: Some(key.clone()), base_url: Some(base.clone()), preset: get_string_flag(&parsed.flags, "preset"), timeout_ms: timeout, @@ -642,7 +643,7 @@ fn persist_login( let mut cfg = upsert_profile(existing.unwrap_or_default(), &name, profile); cfg.active_profile = name.clone(); if !parsed.flag_true("yes") && term::is_interactive() { - if let Ok(models) = fetch_models(&base, Some(key)) { + if let Ok(models) = fetch_models(&base, Some(&key)) { if let Ok(id) = pick_model(&models, None, "Default model") { if let Some(p) = cfg.profiles.get_mut(&name) { p.default_model = Some(id); @@ -674,7 +675,7 @@ fn persist_login( "{} {} {}", term::ok("Signed in."), term::dim(&format!("via {source}")), - term::dim(&format!("key {}", mask_api_key(Some(key)))) + term::dim(&format!("key {}", mask_api_key(Some(&key)))) ); println!("{} {}", term::dim("Saved"), path.display()); Ok(0) @@ -711,6 +712,23 @@ fn pick_ids(models: &[crate::http::CatalogModel]) -> Vec { ids } +fn pick_list( + title: &str, + header: &[String], + items: &[String], + current: Option, +) -> Result { + #[cfg(feature = "native")] + { + crate::tui::pick_with_header(title, header, items, current) + } + #[cfg(not(feature = "native"))] + { + let _ = header; + term::pick(title, items, current) + } +} + fn pick_model( models: &[crate::http::CatalogModel], current: Option<&str>, @@ -721,16 +739,15 @@ fn pick_model( return Err("No models in catalog.".into()); } let current_id = current.map(display_model_id); - let query = term::prompt("Filter models (Enter lists top 30): ")?; - let ranked = term::rank_ids(&query, &ids); - let shown: Vec = ranked.into_iter().take(30).collect(); - if shown.is_empty() { - return Err("No models matched.".into()); - } - let labels: Vec = shown.iter().map(|id| model_pick_label(id)).collect(); - let shown_idx = current_id.and_then(|id| shown.iter().position(|s| s == id)); - let idx = term::pick(title, &labels, shown_idx)?; - Ok(shown[idx].clone()) + let labels: Vec = ids.iter().map(|id| model_pick_label(id)).collect(); + let shown_idx = current_id.and_then(|id| ids.iter().position(|s| s == id)); + let idx = pick_list( + title, + &["type to search, enter to pin".into()], + &labels, + shown_idx, + )?; + Ok(ids[idx].clone()) } fn set_model_slot(profile: &mut Profile, slot: &str, id: String) { @@ -1165,6 +1182,10 @@ fn config_settings_frame( let mut rows: Vec = Vec::new(); let mut kinds: Vec> = Vec::new(); fn section(rows: &mut Vec, kinds: &mut Vec>, name: &str) { + if !rows.is_empty() { + rows.push(SettingRow::Gap); + kinds.push(None); + } rows.push(SettingRow::Section(name.into())); kinds.push(None); } @@ -1340,7 +1361,9 @@ fn config_settings_loop( crate::tui::SettingsOutcome::Close | crate::tui::SettingsOutcome::Stay => None, }; if let Some(Err(err)) = result { - eprintln!("{}", term::err(&err)); + if err != "Cancelled." { + eprintln!("{}", term::err(&err)); + } } } } @@ -1358,7 +1381,10 @@ fn config_edit_row( let mut next = parsed.clone(); next.command = "keys".into(); next.passthrough = vec!["use".into()]; - run_keys(&next, env) + match run_keys(&next, env) { + Err(err) if err == "Cancelled." => Ok(0), + other => other, + } } SettingKind::Model(slot) => { let existing = load_config_if_present(path); @@ -1439,44 +1465,55 @@ fn config_account_actions( parsed: &ParsedArgs, env: &BTreeMap, ) -> Result { - let actions = [ - "Switch account", - "Add account", - "Re-authenticate (login)", - "Log out", - ]; - let idx = term::pick( + let path = config_path(parsed, env); + let cfg = load_config_if_present(&path).unwrap_or_default(); + let mut names: Vec = cfg.profiles.keys().cloned().collect(); + names.sort(); + let mut labels: Vec = names + .iter() + .map(|n| { + let p = cfg.profiles.get(n); + let key = mask_api_key(p.and_then(|p| p.api_key.as_deref())); + let mark = if n == &cfg.active_profile { " ●" } else { "" }; + format!("{n} · {key}{mark}") + }) + .collect(); + let add_idx = labels.len(); + labels.push("+ Add account".into()); + labels.push("Re-authenticate (login)".into()); + labels.push("Log out".into()); + let current = names.iter().position(|n| n == &cfg.active_profile); + let idx = pick_list( "Account", - &actions.iter().map(|s| s.to_string()).collect::>(), - Some(0), + &["enter to switch newest profiles are listed too".into()], + &labels, + current, )?; - match idx { - 0 => { - let mut next = parsed.clone(); - next.passthrough = Vec::new(); - run_auth_switch(&next, env) - } - 1 => { - let name = term::prompt("New account name (Enter for \"default\"): ")?; - let name = name.trim(); - let name = if name.is_empty() { "default" } else { name }; - if !valid_account_name(name) { - return Err(format!( - "Invalid account name \"{name}\". Use letters, digits, \".\", \"_\", \"-\"." - )); - } - let mut flags = parsed.flags.clone(); - flags.insert("profile".into(), FlagValue::Value(name.into())); - let next = ParsedArgs { - command: "login".into(), - flags, - passthrough: Vec::new(), - }; - run_login(&next, env) + if idx < names.len() { + return run_account_use(parsed, env, &names[idx]); + } + if idx == add_idx { + let name = term::prompt("New account name (Enter for \"default\"): ")?; + let name = name.trim(); + let name = if name.is_empty() { "default" } else { name }; + if !valid_account_name(name) { + return Err(format!( + "Invalid account name \"{name}\". Use letters, digits, \".\", \"_\", \"-\"." + )); } - 2 => run_login(parsed, env), - _ => run_logout(parsed, env), + let mut flags = parsed.flags.clone(); + flags.insert("profile".into(), FlagValue::Value(name.into())); + let next = ParsedArgs { + command: "login".into(), + flags, + passthrough: Vec::new(), + }; + return run_login(&next, env); + } + if idx == add_idx + 1 { + return run_login(parsed, env); } + run_logout(parsed, env) } /// `x` on a row: clear the override so the built-in default applies again. @@ -1973,7 +2010,7 @@ fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> Result { let (_path, _cfg, base, api_key) = keys_credential(parsed, env)?; - let rows = fetch_keys(&base, &api_key)?; + let rows = crate::http::keys_newest_first(fetch_keys(&base, &api_key)?); if parsed.flag_true("json") { let payload: Vec<_> = rows .iter() @@ -2049,10 +2086,12 @@ fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> Result { let (path, mut cfg, base, api_key) = keys_credential(parsed, env)?; - let rows: Vec<_> = fetch_keys(&base, &api_key)? - .into_iter() - .filter(|r| r.active) - .collect(); + let rows = crate::http::keys_newest_first( + fetch_keys(&base, &api_key)? + .into_iter() + .filter(|r| r.active) + .collect(), + ); if rows.is_empty() { return Err(hint("No active keys. Create one: {bin} keys create")); } @@ -2077,14 +2116,24 @@ fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> Result = rows .iter() - .map(|r| format!("{} · {}", r.name, r.masked)) + .enumerate() + .map(|(i, r)| key_pick_label(r, current == Some(i))) .collect(); - let current = rows - .iter() - .position(|r| is_active_key_row(&r.masked, Some(&api_key))); - let idx = term::pick("Which key should this profile use?", &labels, current)?; + let idx = pick_list( + "API key", + &[ + "newest first · type to search".into(), + format!("current {}", mask_api_key(Some(&api_key))), + ], + &labels, + current, + )?; rows[idx].clone() } else { return Err(hint( @@ -2155,6 +2204,31 @@ fn run_keys(parsed: &ParsedArgs, env: &BTreeMap) -> Result String { + let Ok(rows) = fetch_keys(base, current) else { + return current.to_string(); + }; + let rows = crate::http::keys_newest_first(rows.into_iter().filter(|r| r.active).collect()); + let Some(latest) = rows.first() else { + return current.to_string(); + }; + if is_active_key_row(&latest.masked, Some(current)) { + return current.to_string(); + } + reveal_key(base, current, &latest.hash).unwrap_or_else(|_| current.to_string()) +} + +fn key_pick_label(row: &crate::http::RemoteKey, current: bool) -> String { + let mut parts = vec![row.name.clone(), row.masked.clone()]; + if let Some(created) = row.created_at.as_deref() { + parts.push(created.get(..10).unwrap_or(created).to_string()); + } + if current { + parts.push("●".into()); + } + parts.join(" · ") +} + fn stored_api_key( parsed: &ParsedArgs, env: &BTreeMap, @@ -2237,8 +2311,10 @@ fn launcher_palette( entries.push(PaletteEntry::new("login", "sign in / add key", "account", "Login / sign in")); } entries.push(PaletteEntry::new("model…", "switch session default", "configure", "Switch model")); + entries.push(PaletteEntry::new("account…", "switch profile", "configure", "Switch account")); + entries.push(PaletteEntry::new("key…", "switch API key", "configure", "Switch key")); entries.push(PaletteEntry::new("config…", "accounts · keys · agent", "configure", "Config")); - entries.push(PaletteEntry::new("quit", "esc works too", "", "Quit")); + entries.push(PaletteEntry::new("quit", "esc works too", "configure", "Quit")); (header, entries) } @@ -2279,8 +2355,10 @@ fn launcher_palette( entries.push(InlineEntry::new("login", "sign in / add key", "account", "Login / sign in")); } entries.push(InlineEntry::new("model…", "switch session default", "configure", "Switch model")); + entries.push(InlineEntry::new("account…", "switch profile", "configure", "Switch account")); + entries.push(InlineEntry::new("key…", "switch API key", "configure", "Switch key")); entries.push(InlineEntry::new("config…", "accounts · keys · agent", "configure", "Config")); - entries.push(InlineEntry::new("quit", "esc works too", "", "Quit")); + entries.push(InlineEntry::new("quit", "esc works too", "configure", "Quit")); (header, entries) } @@ -2493,29 +2571,26 @@ fn launcher_dispatch( } return Ok(LauncherNext::Continue); } - if action == "Switch account / key" { - let cfg = load_config_if_present(path).unwrap_or_default(); - let names: Vec = cfg.profiles.keys().cloned().collect(); - if names.len() > 1 { - let current = names.iter().position(|n| n == &cfg.active_profile); - match term::pick("Account", &names, current) { - Ok(pick) => { - if let Err(err) = run_account_use(parsed, env, &names[pick]) { - eprintln!("{}", term::err(&err)); - } - } - Err(err) => { - if err != "Cancelled." { - eprintln!("{}", term::err(&err)); - } - } - } + if action == "Switch account" || action == "Switch account / key" { + match config_account_actions(parsed, env) { + Ok(_) => {} + Err(err) if err == "Cancelled." => {} + Err(err) => eprintln!("{}", term::err(&err)), + } + return Ok(LauncherNext::Continue); + } + if action == "Switch key" { + if !launcher_signed_in(path, parsed, env) { + eprintln!("{}", term::err("Sign in first (Login / sign in).")); + return Ok(LauncherNext::Continue); } let mut next = parsed.clone(); next.command = "keys".into(); next.passthrough = vec!["use".into()]; - if let Err(err) = run_keys(&next, env) { - eprintln!("{}", term::err(&err)); + match run_keys(&next, env) { + Ok(_) => {} + Err(err) if err == "Cancelled." => {} + Err(err) => eprintln!("{}", term::err(&err)), } return Ok(LauncherNext::Continue); } diff --git a/src/http.rs b/src/http.rs index 6d0a06a..a840df0 100644 --- a/src/http.rs +++ b/src/http.rs @@ -443,6 +443,16 @@ pub fn is_active_key_row(masked: &str, api_key: Option<&str>) -> bool { !key.is_empty() && prefix.len() >= 12 && key.starts_with(prefix) } +/// Newest `created_at` first. Missing timestamps sort last. +pub fn keys_newest_first(mut keys: Vec) -> Vec { + keys.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.hash.cmp(&a.hash)) + }); + keys +} + #[cfg(test)] mod tests { use super::*; @@ -488,6 +498,47 @@ mod tests { assert_eq!(me.display_label(), "duyet · a@b.co"); } + #[test] + fn keys_newest_first_orders_by_created_at() { + let keys = vec![ + RemoteKey { + name: "old".into(), + hash: "h1".into(), + masked: "sk-ar-v1-aaaa".into(), + created_at: Some("2026-01-01T00:00:00Z".into()), + last_used_at: None, + active: true, + can_reveal: true, + }, + RemoteKey { + name: "new".into(), + hash: "h2".into(), + masked: "sk-ar-v1-bbbb".into(), + created_at: Some("2026-08-24T12:00:00Z".into()), + last_used_at: None, + active: true, + can_reveal: true, + }, + RemoteKey { + name: "undated".into(), + hash: "h0".into(), + masked: "sk-ar-v1-cccc".into(), + created_at: None, + last_used_at: None, + active: true, + can_reveal: true, + }, + ]; + let sorted = keys_newest_first(keys); + assert_eq!( + sorted + .iter() + .map(|k| k.name.as_str()) + .collect::>(), + vec!["new", "old", "undated"] + ); + } + #[test] fn me_display_label_falls_back_gracefully() { let mut me = MeInfo::default(); diff --git a/src/tui/keys.rs b/src/tui/keys.rs index cfba58c..83ba3dc 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -72,10 +72,10 @@ pub fn map_key(surface: Surface, key: KeyEvent) -> Action { pub fn hint_line(surface: Surface) -> &'static str { match surface { - Surface::Launcher => "↑↓/jk move ↵ select q/esc quit", - Surface::Settings => "↑↓/jk move ↵ edit x reset q/esc close", - Surface::Picker => "type to search ↑↓ move ↵ select esc cancel", - Surface::Palette => "type to filter ↑↓ move ↵ run esc quit", + Surface::Launcher => "↑↓ move ↵ select q/esc quit", + Surface::Settings => "↑↓ move ↵ edit x reset q/esc close", + Surface::Picker => "type to search ↑↓ move ↵ select esc cancel", + Surface::Palette => "type to filter ↑↓ move ↵ run esc quit", } } diff --git a/src/tui/live.rs b/src/tui/live.rs index 342a4d5..84bbab2 100644 --- a/src/tui/live.rs +++ b/src/tui/live.rs @@ -48,11 +48,22 @@ struct LiveTerminal { terminal: Terminal>, } +/// Drop any key still in the queue (the Enter that closed the previous +/// screen, key-repeat, Release). Without this, the next picker can +/// immediately confirm the current row — looks like switch did nothing. +fn drain_pending_events() { + while event::poll(Duration::from_millis(0)).unwrap_or(false) { + let _ = event::read(); + } +} + impl LiveTerminal { fn start() -> Result { enable_raw_mode().map_err(|e| e.to_string())?; + drain_pending_events(); let mut out = stdout(); execute!(out, EnterAlternateScreen).map_err(|e| e.to_string())?; + drain_pending_events(); let backend = CrosstermBackend::new(stdout()); let terminal = Terminal::new(backend).map_err(|e| e.to_string())?; Ok(Self { terminal }) diff --git a/src/tui/state.rs b/src/tui/state.rs index 8eafe89..61a281c 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -194,10 +194,12 @@ pub enum Tone { Muted, } -/// One row of the settings screen: a section header or an editable entry. +/// One row of the settings screen: a section header, spacer, or editable entry. #[derive(Debug, Clone)] pub enum SettingRow { Section(String), + /// Blank line between sections — not selectable. + Gap, Entry { label: String, value: String, @@ -517,6 +519,7 @@ mod tests { value: "sk-ar-v1-ab…wxyz".into(), tone: Tone::Muted, }, + SettingRow::Gap, SettingRow::Section("Model".into()), SettingRow::Entry { label: "default".into(), @@ -535,11 +538,11 @@ mod tests { s.apply(Action::Down); assert_eq!(s.cursor, 2); s.apply(Action::Down); - assert_eq!(s.cursor, 4); + assert_eq!(s.cursor, 5); // skips Gap + Section s.apply(Action::Down); // wraps back to first entry assert_eq!(s.cursor, 1); s.apply(Action::Up); // wraps up to last entry - assert_eq!(s.cursor, 4); + assert_eq!(s.cursor, 5); } #[test] diff --git a/src/tui/view.rs b/src/tui/view.rs index be5a17c..5d6d375 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -14,21 +14,28 @@ use super::state::{MenuState, PaletteEntry, PaletteState, PickerState, SettingRo use super::theme; /// Preferred dialog width; shrinks on narrow terminals. -const DIALOG_PREF_WIDTH: u16 = 52; +const DIALOG_PREF_WIDTH: u16 = 56; /// Minimum usable dialog width before we fill almost the whole terminal. const DIALOG_MIN_WIDTH: u16 = 28; /// Settings screen is wider — model ids need room next to their labels. -const SETTINGS_PREF_WIDTH: u16 = 64; +const SETTINGS_PREF_WIDTH: u16 = 68; +/// Inner inset (cols / rows) so content isn't flush against the border. +const INSET_X: u16 = 1; +const INSET_Y: u16 = 1; pub fn render_picker(frame: &mut Frame, state: &PickerState) { let area = frame.area(); + let area = inset(area, 1, 0); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1 + state.header.len() as u16), + Constraint::Length(1), Constraint::Length(3), + Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), + Constraint::Length(1), ]) .split(area); @@ -45,7 +52,7 @@ pub fn render_picker(frame: &mut Frame, state: &PickerState) { .border_style(theme::muted()) .title(Span::styled(" search ", theme::muted())), ); - frame.render_widget(search, chunks[1]); + frame.render_widget(search, chunks[2]); let filtered = state.filtered(); let items: Vec = filtered @@ -73,10 +80,10 @@ pub fn render_picker(frame: &mut Frame, state: &PickerState) { ); let mut list_state = ListState::default() .with_selected(Some(state.cursor.min(filtered.len().saturating_sub(1)))); - frame.render_stateful_widget(list, chunks[2], &mut list_state); + frame.render_stateful_widget(list, chunks[4], &mut list_state); let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[3]); + frame.render_widget(footer, chunks[6]); } pub fn render_menu(frame: &mut Frame, state: &MenuState) { @@ -96,28 +103,31 @@ pub fn render_menu(frame: &mut Frame, state: &MenuState) { .border_style(theme::brand()) .title(Span::styled(format!(" ▲ {} ", state.title), theme::brand())) .style(Style::default().bg(theme::surface_rgb())); - let inner = block.inner(dialog); + let inner = inset(block.inner(dialog), INSET_X, INSET_Y); frame.render_widget(block, dialog); - // status | actions | hint + // status | pad | rule | pad | actions | pad | hint let status_h = state.header.len().max(1) as u16; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(status_h), Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), Constraint::Min(state.items.len().max(1) as u16), Constraint::Length(1), + Constraint::Length(1), ]) .split(inner); render_status_lines(frame, chunks[0], &state.header); let rule = Paragraph::new(Span::styled( - "─".repeat(chunks[1].width as usize), + "─".repeat(chunks[2].width as usize), theme::muted(), )); - frame.render_widget(rule, chunks[1]); + frame.render_widget(rule, chunks[2]); let items: Vec = state .items @@ -139,21 +149,21 @@ pub fn render_menu(frame: &mut Frame, state: &MenuState) { let list = List::new(items); let mut list_state = ListState::default().with_selected(Some(state.cursor)); - frame.render_stateful_widget(list, chunks[2], &mut list_state); + frame.render_stateful_widget(list, chunks[4], &mut list_state); let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[3]); + frame.render_widget(footer, chunks[6]); } fn dialog_height(state: &MenuState) -> u16 { - // borders(2) + status + rule(1) + items + hint(1) + // borders(2) + inset(2) + status + pads(3) + rule(1) + items + hint(1) let status = state.header.len().max(1) as u16; let items = state.items.len().max(1) as u16; - 2 + status + 1 + items + 1 + 2 + 2 + status + 3 + 1 + items + 1 } /// Palette preferred width — detail columns need a little more room. -const PALETTE_PREF_WIDTH: u16 = 60; +const PALETTE_PREF_WIDTH: u16 = 64; /// Command palette: floating input on top, fuzzy results below, groups /// rendered only where they change. Same centered-card language as the menu. @@ -169,8 +179,10 @@ pub fn render_palette(frame: &mut Frame, state: &PaletteState) { // Group headers share the result area, so the dialog must grow by the // number of distinct groups among the visible rows. let groups = palette_groups(state, &filtered, visible); - // borders(2) + input(1) + rule(1) + rows + group headers + hint(1) - let height = 2 + 1 + 1 + visible.max(1) as u16 + groups as u16 + 1; + // borders(2) + inset(2) + input + pads + rule + rows + group headers + // + blank between groups + hint + let between = groups.saturating_sub(1); + let height = 2 + 2 + 1 + 3 + 1 + visible.max(1) as u16 + groups as u16 + between as u16 + 1; let dialog = centered_dialog(area, height, PALETTE_PREF_WIDTH); frame.render_widget(Clear, dialog); @@ -179,15 +191,18 @@ pub fn render_palette(frame: &mut Frame, state: &PaletteState) { .border_style(theme::brand()) .title(Span::styled(" ▲ anyr ", theme::brand())) .style(Style::default().bg(theme::surface_rgb())); - let inner = block.inner(dialog); + let inner = inset(block.inner(dialog), INSET_X, INSET_Y); frame.render_widget(block, dialog); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // input line + Constraint::Length(1), // pad Constraint::Length(1), // rule + Constraint::Length(1), // pad Constraint::Min(visible.max(1) as u16), // results + Constraint::Length(1), // pad Constraint::Length(1), // hint ]) .split(inner); @@ -200,24 +215,24 @@ pub fn render_palette(frame: &mut Frame, state: &PaletteState) { frame.render_widget(input, chunks[0]); frame.render_widget( Paragraph::new(Span::styled( - "─".repeat(chunks[1].width as usize), + "─".repeat(chunks[2].width as usize), theme::muted(), )), - chunks[1], + chunks[2], ); if filtered.is_empty() { frame.render_widget( Paragraph::new(Span::styled("no matches", theme::muted())), - chunks[2], + chunks[4], ); } else { - let rows = palette_rows(state, &filtered, visible, chunks[2].width as usize); - frame.render_widget(Paragraph::new(rows), chunks[2]); + let rows = palette_rows(state, &filtered, visible, chunks[4].width as usize); + frame.render_widget(Paragraph::new(rows), chunks[4]); } let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[3]); + frame.render_widget(footer, chunks[6]); } /// Number of distinct groups among the first `visible` filtered entries — @@ -226,6 +241,9 @@ fn palette_groups(state: &PaletteState, filtered: &[usize], visible: usize) -> u let mut groups: Vec<&str> = Vec::new(); for &entry_i in filtered.iter().take(visible) { let g = state.entries[entry_i].group.as_str(); + if g.is_empty() { + continue; + } if !groups.contains(&g) { groups.push(g); } @@ -247,10 +265,15 @@ fn palette_rows( for (row_i, &entry_i) in filtered.iter().take(visible).enumerate() { let entry: &PaletteEntry = &state.entries[entry_i]; if last_group != Some(entry.group.as_str()) { - rows.push(Line::from(Span::styled( - format!(" {}", entry.group.to_ascii_uppercase()), - theme::muted(), - ))); + if last_group.is_some() { + rows.push(Line::from("")); + } + if !entry.group.is_empty() { + rows.push(Line::from(Span::styled( + format!(" {}", entry.group.to_ascii_uppercase()), + theme::muted(), + ))); + } last_group = Some(&entry.group); } let selected = row_i == cursor_row; @@ -290,7 +313,7 @@ pub fn render_settings(frame: &mut Frame, state: &SettingsState) { .border_style(theme::brand()) .title(Span::styled(format!(" ▲ {} ", state.title), theme::brand())) .style(Style::default().bg(theme::surface_rgb())); - let inner = block.inner(dialog); + let inner = inset(block.inner(dialog), INSET_X, INSET_Y); frame.render_widget(block, dialog); let status_h = state.header.len().max(1) as u16; @@ -299,37 +322,40 @@ pub fn render_settings(frame: &mut Frame, state: &SettingsState) { .constraints([ Constraint::Length(status_h), Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), Constraint::Min(state.rows.len().max(1) as u16), Constraint::Length(1), + Constraint::Length(1), ]) .split(inner); render_status_lines(frame, chunks[0], &state.header); let rule = Paragraph::new(Span::styled( - "─".repeat(chunks[1].width as usize), + "─".repeat(chunks[2].width as usize), theme::muted(), )); - frame.render_widget(rule, chunks[1]); + frame.render_widget(rule, chunks[2]); - let inner_w = chunks[2].width as usize; + let inner_w = chunks[4].width as usize; let lines: Vec = state .rows .iter() .enumerate() .map(|(i, row)| settings_row_line(row, i == state.cursor, inner_w)) .collect(); - frame.render_widget(Paragraph::new(lines), chunks[2]); + frame.render_widget(Paragraph::new(lines), chunks[4]); let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[3]); + frame.render_widget(footer, chunks[6]); } fn settings_dialog_height(state: &SettingsState) -> u16 { - // borders(2) + status + rule(1) + rows + hint(1) + // borders(2) + inset(2) + status + pads(3) + rule(1) + rows + hint(1) let status = state.header.len().max(1) as u16; let rows = state.rows.len().max(1) as u16; - 2 + status + 1 + rows + 1 + 2 + 2 + status + 3 + 1 + rows + 1 } fn tone_style(tone: Tone) -> Style { @@ -349,6 +375,7 @@ fn settings_row_line(row: &SettingRow, selected: bool, inner_w: usize) -> Line<' format!(" {}", name.to_ascii_uppercase()), theme::muted(), )), + SettingRow::Gap => Line::from(""), SettingRow::Entry { label, value, tone } => { let marker = if selected { "❯ " } else { " " }; let marker_style = if selected { @@ -374,6 +401,21 @@ fn settings_row_line(row: &SettingRow, selected: bool, inner_w: usize) -> Line<' } } +/// Shrink a rect by `x` columns and `y` rows on each side. +fn inset(area: Rect, x: u16, y: u16) -> Rect { + if area.width == 0 || area.height == 0 { + return area; + } + let x = x.min(area.width.saturating_sub(1) / 2); + let y = y.min(area.height.saturating_sub(1) / 2); + Rect::new( + area.x + x, + area.y + y, + area.width.saturating_sub(x.saturating_mul(2)).max(1), + area.height.saturating_sub(y.saturating_mul(2)).max(1), + ) +} + /// Center a fixed-size dialog; clamp to terminal so narrow TTYs never clip badly. fn centered_dialog(area: Rect, content_height: u16, pref_width: u16) -> Rect { if area.width == 0 || area.height == 0 { @@ -504,7 +546,9 @@ pub fn plain_menu_lines(state: &MenuState, cols: usize) -> Vec { lines.push(format!("{pad_s}│{}│", pad_content(h, content_w))); } } + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); for (i, label) in state.items.iter().enumerate() { let marker = if i == state.cursor { "◆" } else { " " }; @@ -512,6 +556,7 @@ pub fn plain_menu_lines(state: &MenuState, cols: usize) -> Vec { lines.push(format!("{pad_s}│{}│", pad_content(&row, content_w))); } + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); lines.push(format!("{pad_s}│{}│", pad_content(state.hint(), content_w))); lines.push(format!("{pad_s}╰{}╯", "─".repeat(content_w))); @@ -572,10 +617,18 @@ pub fn plain_palette_lines(state: &PaletteState, cols: usize) -> Vec { for (row_i, &entry_i) in filtered.iter().take(visible).enumerate() { let entry = &state.entries[entry_i]; if last_group != Some(entry.group.as_str()) { - lines.push(format!( - "{pad_s}│{}│", - pad_content(&format!(" {}", entry.group.to_ascii_uppercase()), content_w) - )); + if last_group.is_some() { + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); + } + if !entry.group.is_empty() { + lines.push(format!( + "{pad_s}│{}│", + pad_content( + &format!(" {}", entry.group.to_ascii_uppercase()), + content_w + ) + )); + } last_group = Some(&entry.group); } let selected = row_i == cursor_row; @@ -630,11 +683,14 @@ pub fn plain_settings_lines(state: &SettingsState, cols: usize) -> Vec { lines.push(format!("{pad_s}│{}│", pad_content(h, content_w))); } } + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); for (i, row) in state.rows.iter().enumerate() { let line = match row { SettingRow::Section(name) => format!(" {}", name.to_ascii_uppercase()), + SettingRow::Gap => String::new(), SettingRow::Entry { label, value, .. } => { let marker = if i == state.cursor { "◆" } else { " " }; let used = 4 + label.chars().count() + value.chars().count(); @@ -732,6 +788,16 @@ mod tests { } } + #[test] + fn inset_shrinks_and_clamps() { + let r = Rect::new(0, 0, 20, 10); + let i = inset(r, 1, 1); + assert_eq!(i, Rect::new(1, 1, 18, 8)); + let tiny = Rect::new(0, 0, 2, 2); + let i = inset(tiny, 4, 4); + assert!(i.width >= 1 && i.height >= 1); + } + #[test] fn centered_dialog_clamps_to_area() { let tiny = Rect::new(0, 0, 20, 8); @@ -754,6 +820,7 @@ mod tests { value: "duyet".into(), tone: Tone::Normal, }, + SettingRow::Gap, SettingRow::Section("Model".into()), SettingRow::Entry { label: "default".into(), @@ -769,6 +836,11 @@ mod tests { assert!(frame.contains("MODEL"), "{frame}"); assert!(frame.contains("◆ account"), "{frame}"); assert!(frame.contains('╭') && frame.contains('╯'), "{frame}"); + // Blank line between ACCOUNT and MODEL sections. + let dumped: Vec<&str> = frame.lines().collect(); + let acct = dumped.iter().position(|l| l.contains("ACCOUNT")).expect("ACCOUNT"); + let model = dumped.iter().position(|l| l.contains("MODEL")).expect("MODEL"); + assert!(model > acct + 1, "expected padding between sections:\n{frame}"); // Values right-aligned inside the card (before the right border). let row_line = frame .lines() diff --git a/tests/cli.rs b/tests/cli.rs index 39a3059..2eb70dc 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1183,6 +1183,9 @@ profiles: assert!(stdout.contains("claude"), "{stdout}"); assert!(stdout.contains("CONFIGURE"), "{stdout}"); assert!(stdout.contains("config…"), "{stdout}"); + assert!(stdout.contains("account…"), "{stdout}"); + assert!(stdout.contains("key…"), "{stdout}"); + assert!(stdout.contains("model…"), "{stdout}"); assert!(stdout.contains("quit"), "{stdout}"); assert!(stdout.contains('❯'), "palette must show the input line: {stdout}"); assert!( @@ -1231,6 +1234,19 @@ profiles: for section in ["ACCOUNT", "MODEL", "AGENT", "GENERAL"] { assert!(stdout.contains(section), "missing {section} in:\n{stdout}"); } + let dumped: Vec<&str> = stdout.lines().collect(); + let acct = dumped + .iter() + .position(|l| l.contains("ACCOUNT")) + .expect("ACCOUNT"); + let model = dumped + .iter() + .position(|l| l.contains("MODEL")) + .expect("MODEL"); + assert!( + model > acct + 1, + "expected padding between ACCOUNT and MODEL:\n{stdout}" + ); for row in [ "account", "api key",