From f8410e1a28e6c6a1de1edab47af7453c52bc99b5 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Sat, 22 Aug 2026 16:24:38 +0700 Subject: [PATCH 1/5] feat(http): fetch account identity from /v1/me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeInfo carries email, name, username, and balance from GET /v1/me (accepted with sk-ar- inference keys). display_label renders the username · email form used by the launcher and settings screens. Co-Authored-By: Claude Fable 5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/http.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/http.rs b/src/http.rs index 3f73891..6d0a06a 100644 --- a/src/http.rs +++ b/src/http.rs @@ -213,6 +213,61 @@ pub fn fetch_credits(base_url: &str, api_key: &str) -> Result, + pub name: Option, + pub username: Option, + pub balance: Option, +} + +impl MeInfo { + /// `username · email` style label, falling back through name/email/username. + pub fn display_label(&self) -> String { + let handle = self + .username + .as_deref() + .filter(|s| !s.is_empty()) + .or(self.name.as_deref().filter(|s| !s.is_empty())) + .or(self.email.as_deref().filter(|s| !s.is_empty())); + match (handle, self.email.as_deref()) { + (Some(h), Some(e)) if !e.eq_ignore_ascii_case(h) => format!("{h} · {e}"), + (Some(h), _) => h.to_string(), + (None, _) => "—".into(), + } + } +} + +pub fn parse_me(body: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("Invalid /me response: {e}"))?; + Ok(MeInfo { + email: value + .get("email") + .and_then(|v| v.as_str()) + .map(str::to_string), + name: value + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string), + username: value + .get("username") + .and_then(|v| v.as_str()) + .map(str::to_string), + balance: value.get("balance").and_then(|v| v.as_f64()), + }) +} + +pub fn fetch_me(base_url: &str, api_key: &str) -> Result { + let url = join_api(base_url, "/v1/me"); + let (status, body) = http_get(&url, Some(api_key))?; + if !(200..300).contains(&status) { + return Err(format!("Could not fetch account info (HTTP {status}).")); + } + parse_me(&body) +} + pub fn format_usd(n: f64) -> String { if !n.is_finite() { return "$?".into(); @@ -421,6 +476,31 @@ mod tests { assert!(keys[0].can_reveal); } + #[test] + fn parse_me_reads_identity_and_balance() { + let me = parse_me( + r#"{"id":"user_1","email":"a@b.co","name":"duyet","image_url":"https://x/y.png","username":"duyet","balance":129.22}"#, + ) + .unwrap(); + assert_eq!(me.email.as_deref(), Some("a@b.co")); + assert_eq!(me.username.as_deref(), Some("duyet")); + assert_eq!(me.balance, Some(129.22)); + assert_eq!(me.display_label(), "duyet · a@b.co"); + } + + #[test] + fn me_display_label_falls_back_gracefully() { + let mut me = MeInfo::default(); + assert_eq!(me.display_label(), "—"); + me.email = Some("solo@b.co".into()); + assert_eq!(me.display_label(), "solo@b.co"); + me.name = Some("Solo".into()); + assert_eq!(me.display_label(), "Solo · solo@b.co"); + // Email used as handle should not repeat. + me.username = Some("solo@b.co".into()); + assert_eq!(me.display_label(), "solo@b.co"); + } + #[test] fn is_active_key_row_matches_prefix_only() { assert!(is_active_key_row( From 3e4059115b97ee861a0d7f37ac9a44a5e3690dc2 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Sat, 22 Aug 2026 16:25:24 +0700 Subject: [PATCH 2/5] feat(tui): settings screen state, keymap, and rendering SettingsState models grouped rows (section headers + label/value entries) with a cursor that skips sections. Surface::Settings maps x to a new Unset action for resetting a row to its default; j/k, enter, esc/q behave like the launcher. Rendering right-aligns each current value with a tone color (green enabled, teal model id, orange warning, dim unset) inside a wider centered dialog. plain_settings_frame keeps --dump-tui ANSI-free. Co-Authored-By: Claude Fable 5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/tui/keys.rs | 30 +++++- src/tui/live.rs | 38 +++++++- src/tui/mod.rs | 17 +++- src/tui/state.rs | 180 +++++++++++++++++++++++++++++++++++- src/tui/view.rs | 232 +++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 466 insertions(+), 31 deletions(-) diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 5ff840d..62d5e44 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -4,6 +4,7 @@ pub enum Surface { Launcher, Picker, + Settings, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -12,6 +13,8 @@ pub enum Action { Enter, Up, Down, + /// Reset the focused settings row to its default (`x`). + Unset, Backspace, Esc, Char(char), @@ -54,8 +57,10 @@ pub fn map_key(surface: Surface, key: KeyEvent) -> Action { KeyCode::Backspace | KeyCode::Delete => Action::Backspace, KeyCode::Char(c) => match (surface, c) { (Surface::Launcher, 'q' | 'x' | 'Q' | 'X') => Action::Quit, - (Surface::Launcher, 'j') => Action::Down, - (Surface::Launcher, 'k') => Action::Up, + (Surface::Settings, 'q') => Action::Quit, + (Surface::Settings, 'x' | 'X') => Action::Unset, + (Surface::Launcher | Surface::Settings, 'j') => Action::Down, + (Surface::Launcher | Surface::Settings, 'k') => Action::Up, (_, c) => Action::Char(c), }, } @@ -64,6 +69,7 @@ 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", } } @@ -107,4 +113,24 @@ mod tests { ); assert_eq!(a, Action::Quit); } + + #[test] + fn settings_x_resets_and_q_quits() { + let x = map_key( + Surface::Settings, + KeyEvent { + code: KeyCode::Char('x'), + ctrl: false, + }, + ); + assert_eq!(x, Action::Unset); + let q = map_key( + Surface::Settings, + KeyEvent { + code: KeyCode::Char('q'), + ctrl: false, + }, + ); + assert_eq!(q, Action::Quit); + } } diff --git a/src/tui/live.rs b/src/tui/live.rs index a5c41df..c146c0f 100644 --- a/src/tui/live.rs +++ b/src/tui/live.rs @@ -12,8 +12,11 @@ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use super::keys::{map_key, KeyCode, KeyEvent, Surface}; -use super::state::{MenuState, Outcome, PickerState}; -use super::view::{plain_menu_frame, plain_picker_frame, render_menu, render_picker}; +use super::state::{MenuState, Outcome, PickerState, SettingsOutcome, SettingsState}; +use super::view::{ + plain_menu_frame, plain_picker_frame, plain_settings_frame, render_menu, render_picker, + render_settings, +}; pub fn is_interactive() -> bool { use std::io::IsTerminal; @@ -125,3 +128,34 @@ pub fn dump_picker(state: &PickerState, cols: usize) -> String { pub fn dump_menu(state: &MenuState, cols: usize) -> String { plain_menu_frame(state, cols) } + +pub fn run_settings_live(mut state: SettingsState) -> Result { + let mut live = LiveTerminal::start()?; + loop { + live.terminal + .draw(|f| render_settings(f, &state)) + .map_err(|e| e.to_string())?; + if !event::poll(Duration::from_millis(200)).map_err(|e| e.to_string())? { + continue; + } + match event::read().map_err(|e| e.to_string())? { + Event::Resize(_, _) => { + state.apply(super::keys::Action::Resize); + } + Event::Key(ev) => { + let Some(key) = translate_key(ev) else { + continue; + }; + let outcome = state.apply(map_key(Surface::Settings, key)); + if outcome != SettingsOutcome::Stay { + return Ok(outcome); + } + } + _ => {} + } + } +} + +pub fn dump_settings(state: &SettingsState, cols: usize) -> String { + plain_settings_frame(state, cols) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5e93a1c..f4835aa 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -16,8 +16,14 @@ use std::collections::BTreeMap; use crate::parse::ParsedArgs; pub use keys::Action; -pub use live::{dump_menu, dump_picker, is_interactive, run_menu_live, run_picker_live}; -pub use state::{drive_menu, drive_picker, MenuState, Outcome, PickerState}; +pub use live::{ + dump_menu, dump_picker, dump_settings, is_interactive, run_menu_live, run_picker_live, + run_settings_live, +}; +pub use state::{ + drive_menu, drive_picker, MenuState, Outcome, PickerState, SettingRow, SettingsOutcome, + SettingsState, Tone, +}; pub fn wants_dump(parsed: &ParsedArgs, env: &BTreeMap) -> bool { parsed.flag_true("dump-tui") @@ -83,7 +89,12 @@ pub fn run_menu_select( } /// Dump one launcher frame (for `--dump-tui` / `ANYR_TUI_DUMP`). -pub fn dump_menu_select(title: &str, header: Vec, items: Vec, cols: usize) -> String { +pub fn dump_menu_select( + title: &str, + header: Vec, + items: Vec, + cols: usize, +) -> String { dump_menu(&MenuState::new(title, header, items), cols) } diff --git a/src/tui/state.rs b/src/tui/state.rs index 54f90bf..c144d6c 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -82,7 +82,11 @@ impl PickerState { Action::Up => { let n = self.filtered().len(); if n > 0 { - self.cursor = if self.cursor == 0 { n - 1 } else { self.cursor - 1 }; + self.cursor = if self.cursor == 0 { + n - 1 + } else { + self.cursor - 1 + }; } Outcome::Continue } @@ -106,6 +110,7 @@ impl PickerState { self.cursor = 0; Outcome::Continue } + Action::Unset => Outcome::Continue, } } @@ -149,7 +154,11 @@ impl MenuState { Action::Up => { let n = self.items.len(); if n > 0 { - self.cursor = if self.cursor == 0 { n - 1 } else { self.cursor - 1 }; + self.cursor = if self.cursor == 0 { + n - 1 + } else { + self.cursor - 1 + }; } Outcome::Continue } @@ -161,7 +170,7 @@ impl MenuState { Outcome::Continue } Action::Backspace => Outcome::Continue, - Action::Char(_) => Outcome::Continue, + Action::Char(_) | Action::Unset => Outcome::Continue, } } @@ -170,6 +179,112 @@ impl MenuState { } } +/// Color tone for a settings value — drives TUI color and dump annotations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tone { + /// Plain white value. + Normal, + /// Green — enabled / healthy. + Good, + /// Teal — a model id. + Model, + /// Orange — needs attention. + Warn, + /// Dim — unset / default. + Muted, +} + +/// One row of the settings screen: a section header or an editable entry. +#[derive(Debug, Clone)] +pub enum SettingRow { + Section(String), + Entry { + label: String, + value: String, + tone: Tone, + }, +} + +impl SettingRow { + pub fn selectable(&self) -> bool { + matches!(self, SettingRow::Entry { .. }) + } +} + +/// What the settings screen wants the caller to do after a key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsOutcome { + Stay, + /// Edit the entry at the given row index. + Edit(usize), + /// Reset the entry at the given row index to its default (`x`). + Reset(usize), + Close, +} + +/// Cursor-style settings screen: grouped rows, right-aligned current values. +pub struct SettingsState { + pub title: String, + pub header: Vec, + pub rows: Vec, + /// Cursor index into `rows`; always points at an Entry. + pub cursor: usize, +} + +impl SettingsState { + pub fn new(title: impl Into, header: Vec, rows: Vec) -> Self { + let mut state = Self { + title: title.into(), + header, + rows, + cursor: 0, + }; + state.cursor = state.rows.iter().position(|r| r.selectable()).unwrap_or(0); + state + } + + /// Indices of selectable (Entry) rows. + pub fn entries(&self) -> Vec { + self.rows + .iter() + .enumerate() + .filter(|(_, r)| r.selectable()) + .map(|(i, _)| i) + .collect() + } + + pub fn apply(&mut self, action: Action) -> SettingsOutcome { + let entries = self.entries(); + if entries.is_empty() { + return match action { + Action::Quit | Action::Esc => SettingsOutcome::Close, + _ => SettingsOutcome::Stay, + }; + } + let pos = entries.iter().position(|i| *i == self.cursor).unwrap_or(0); + match action { + Action::Quit | Action::Esc => SettingsOutcome::Close, + Action::Enter => SettingsOutcome::Edit(self.cursor), + Action::Unset => SettingsOutcome::Reset(self.cursor), + Action::Up => { + let prev = if pos == 0 { entries.len() - 1 } else { pos - 1 }; + self.cursor = entries[prev]; + SettingsOutcome::Stay + } + Action::Down => { + let next = (pos + 1) % entries.len(); + self.cursor = entries[next]; + SettingsOutcome::Stay + } + Action::Resize | Action::Backspace | Action::Char(_) => SettingsOutcome::Stay, + } + } + + pub fn hint(&self) -> &'static str { + hint_line(Surface::Settings) + } +} + /// Drive state with a scripted key sequence (unit / e2e, no TTY). pub fn drive_picker(state: &mut PickerState, actions: &[Action]) -> Outcome { for action in actions { @@ -209,7 +324,11 @@ mod tests { fn picker_filter_narrows() { let mut s = PickerState::new( "Pick", - vec!["openai/gpt".into(), "anthropic/claude".into(), "google/gemma".into()], + vec![ + "openai/gpt".into(), + "anthropic/claude".into(), + "google/gemma".into(), + ], Some(0), ); s.apply(Action::Char('c')); @@ -246,4 +365,57 @@ mod tests { let out = drive_menu(&mut s, &[Action::Down, Action::Enter]); assert_eq!(out, Outcome::Selected(1)); } + + fn sample_settings() -> SettingsState { + SettingsState::new( + "Config", + vec![], + vec![ + SettingRow::Section("Account".into()), + SettingRow::Entry { + label: "account".into(), + value: "duyet".into(), + tone: Tone::Normal, + }, + SettingRow::Entry { + label: "api key".into(), + value: "sk-ar-v1-ab…wxyz".into(), + tone: Tone::Muted, + }, + SettingRow::Section("Model".into()), + SettingRow::Entry { + label: "default".into(), + value: "auto".into(), + tone: Tone::Model, + }, + ], + ) + } + + #[test] + fn settings_cursor_skips_sections() { + let mut s = sample_settings(); + // Cursor starts on the first Entry (row 1), never the Section (row 0). + assert_eq!(s.cursor, 1); + s.apply(Action::Down); + assert_eq!(s.cursor, 2); + s.apply(Action::Down); + assert_eq!(s.cursor, 4); + 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); + } + + #[test] + fn settings_edit_and_reset_report_row_index() { + let mut s = sample_settings(); + s.cursor = 2; + assert_eq!(s.apply(Action::Enter), SettingsOutcome::Edit(2)); + assert_eq!(s.apply(Action::Unset), SettingsOutcome::Reset(2)); + assert_eq!(s.apply(Action::Esc), SettingsOutcome::Close); + assert_eq!(s.apply(Action::Quit), SettingsOutcome::Close); + // Typing does nothing on a settings screen. + assert_eq!(s.apply(Action::Char('a')), SettingsOutcome::Stay); + } } diff --git a/src/tui/view.rs b/src/tui/view.rs index a961072..3b78dba 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -10,13 +10,15 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; use ratatui::Frame; -use super::state::{MenuState, PickerState}; +use super::state::{MenuState, PickerState, SettingRow, SettingsState, Tone}; use super::theme; /// Preferred dialog width; shrinks on narrow terminals. const DIALOG_PREF_WIDTH: u16 = 52; /// 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; pub fn render_picker(frame: &mut Frame, state: &PickerState) { let area = frame.area(); @@ -67,14 +69,10 @@ pub fn render_picker(frame: &mut Frame, state: &PickerState) { Block::default() .borders(Borders::ALL) .border_style(theme::muted()) - .title(Span::styled( - format!(" {} ", state.title), - theme::title(), - )), + .title(Span::styled(format!(" {} ", state.title), theme::title())), ); - let mut list_state = ListState::default().with_selected(Some(state.cursor.min( - filtered.len().saturating_sub(1), - ))); + 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); let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); @@ -96,10 +94,7 @@ pub fn render_menu(frame: &mut Frame, state: &MenuState) { let block = Block::default() .borders(Borders::ALL) .border_style(theme::brand()) - .title(Span::styled( - format!(" ▲ {} ", state.title), - theme::brand(), - )) + .title(Span::styled(format!(" ▲ {} ", state.title), theme::brand())) .style(Style::default().bg(theme::surface_rgb())); let inner = block.inner(dialog); frame.render_widget(block, dialog); @@ -157,15 +152,114 @@ fn dialog_height(state: &MenuState) -> u16 { 2 + status + 1 + items + 1 } +/// Settings screen: same centered-dialog shape, grouped rows with +/// right-aligned colored values. +pub fn render_settings(frame: &mut Frame, state: &SettingsState) { + let area = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(theme::backdrop_rgb()).fg(Color::Reset)), + area, + ); + + let dialog = centered_dialog(area, settings_dialog_height(state), SETTINGS_PREF_WIDTH); + frame.render_widget(Clear, dialog); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::brand()) + .title(Span::styled(format!(" ▲ {} ", state.title), theme::brand())) + .style(Style::default().bg(theme::surface_rgb())); + let inner = block.inner(dialog); + frame.render_widget(block, dialog); + + 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::Min(state.rows.len().max(1) as u16), + 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), + theme::muted(), + )); + frame.render_widget(rule, chunks[1]); + + let inner_w = chunks[2].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]); + + let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); + frame.render_widget(footer, chunks[3]); +} + +fn settings_dialog_height(state: &SettingsState) -> u16 { + // borders(2) + status + 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 +} + +fn tone_style(tone: Tone) -> Style { + match tone { + Tone::Normal => theme::white(), + Tone::Good => theme::success(), + Tone::Model => theme::model(), + Tone::Warn => Style::default().fg(theme::rgb(230, 160, 60)), + Tone::Muted => theme::muted(), + } +} + +/// One settings row as styled spans: `❯ label value` (value right-aligned). +fn settings_row_line(row: &SettingRow, selected: bool, inner_w: usize) -> Line<'static> { + match row { + SettingRow::Section(name) => Line::from(Span::styled( + format!(" {}", name.to_ascii_uppercase()), + theme::muted(), + )), + SettingRow::Entry { label, value, tone } => { + let marker = if selected { "❯ " } else { " " }; + let marker_style = if selected { + theme::accent() + } else { + theme::muted() + }; + let label_style = if selected { + theme::selected() + } else { + theme::white() + }; + // Right-align the value: pad the label column to fill the gap. + let used = 2 + label.chars().count() + value.chars().count(); + let gap = inner_w.saturating_sub(used).max(1); + let padded_label = format!("{label}{}", " ".repeat(gap)); + Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::styled(padded_label, label_style), + Span::styled(value.clone(), tone_style(*tone)), + ]) + } + } +} + /// 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 { return area; } let max_w = area.width.saturating_sub(2).max(1); - let width = pref_width - .min(max_w) - .max(DIALOG_MIN_WIDTH.min(max_w)); + let width = pref_width.min(max_w).max(DIALOG_MIN_WIDTH.min(max_w)); let max_h = area.height.saturating_sub(0).max(1); let height = content_height.min(max_h).max(5.min(max_h)); let x = area.x + (area.width.saturating_sub(width)) / 2; @@ -298,10 +392,7 @@ pub fn plain_menu_lines(state: &MenuState, cols: usize) -> Vec { } lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); - lines.push(format!( - "{pad_s}│{}│", - pad_content(state.hint(), content_w) - )); + lines.push(format!("{pad_s}│{}│", pad_content(state.hint(), content_w))); lines.push(format!("{pad_s}╰{}╯", "─".repeat(content_w))); lines } @@ -327,6 +418,57 @@ pub fn plain_menu_frame(state: &MenuState, cols: usize) -> String { lines.join("\n") } +/// ANSI-free settings frame for `--dump-tui` and unit tests. +pub fn plain_settings_lines(state: &SettingsState, cols: usize) -> Vec { + let term_w = cols.max(DIALOG_MIN_WIDTH as usize); + let inner = (SETTINGS_PREF_WIDTH as usize) + .min(term_w.saturating_sub(2)) + .max(DIALOG_MIN_WIDTH as usize) + .min(term_w); + let pad = term_w.saturating_sub(inner) / 2; + let pad_s = " ".repeat(pad); + let content_w = inner.saturating_sub(2); + + let mut lines = Vec::new(); + let title_raw = format!(" ▲ {} ", state.title); + let title = truncate(&title_raw, content_w); + let dash_n = content_w.saturating_sub(title.chars().count()); + lines.push(format!("{pad_s}╭{title}{}╮", "─".repeat(dash_n))); + + if state.header.is_empty() { + lines.push(format!("{pad_s}│{}│", pad_content("—", content_w))); + } else { + for h in &state.header { + lines.push(format!("{pad_s}│{}│", pad_content(h, content_w))); + } + } + lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); + + for (i, row) in state.rows.iter().enumerate() { + let line = match row { + SettingRow::Section(name) => format!(" {}", name.to_ascii_uppercase()), + SettingRow::Entry { label, value, .. } => { + let marker = if i == state.cursor { "◆" } else { " " }; + let used = 4 + label.chars().count() + value.chars().count(); + let gap = content_w.saturating_sub(used).max(1); + format!("{marker} {label}{}{value}", " ".repeat(gap)) + } + }; + lines.push(format!("{pad_s}│{}│", pad_content(&line, 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))); + lines +} + +pub fn plain_settings_frame(state: &SettingsState, cols: usize) -> String { + let mut lines = plain_settings_lines(state, cols); + lines.push(String::new()); + lines.join("\n") +} + fn truncate(s: &str, max: usize) -> String { if max == 0 { return String::new(); @@ -357,7 +499,10 @@ mod tests { assert!(frame.contains("◆ Launch claude"), "{frame}"); assert!(frame.contains("Config"), "{frame}"); assert!(frame.contains("Quit"), "{frame}"); - assert!(frame.contains('╭') && frame.contains('╯'), "dialog box: {frame}"); + assert!( + frame.contains('╭') && frame.contains('╯'), + "dialog box: {frame}" + ); } #[test] @@ -407,4 +552,51 @@ mod tests { assert!(d.height <= tiny.height); assert!(d.width >= 1); } + + #[test] + fn dump_settings_is_ansi_free_and_grouped() { + use super::super::state::{SettingRow, SettingsState, Tone}; + let state = SettingsState::new( + "Config", + vec!["account duyet · me@example.co".into()], + vec![ + SettingRow::Section("Account".into()), + SettingRow::Entry { + label: "account".into(), + value: "duyet".into(), + tone: Tone::Normal, + }, + SettingRow::Section("Model".into()), + SettingRow::Entry { + label: "default".into(), + value: "auto".into(), + tone: Tone::Model, + }, + ], + ); + let frame = plain_settings_frame(&state, 80); + assert!(!frame.contains('\u{1b}'), "must be ANSI-free: {frame}"); + assert!(frame.contains("▲ Config"), "{frame}"); + assert!(frame.contains("ACCOUNT"), "{frame}"); + assert!(frame.contains("MODEL"), "{frame}"); + assert!(frame.contains("◆ account"), "{frame}"); + assert!(frame.contains('╭') && frame.contains('╯'), "{frame}"); + // Values right-aligned inside the card (before the right border). + let row_line = frame + .lines() + .find(|l| l.contains("default") && l.contains("auto")) + .expect("default row"); + assert!( + row_line + .trim_end() + .trim_end_matches('│') + .trim_end() + .ends_with("auto"), + "{row_line}" + ); + // Narrow terminals still render. + let narrow = plain_settings_frame(&state, 30); + assert!(!narrow.contains('\u{1b}')); + assert!(narrow.contains("▲ Config"), "{narrow}"); + } } From 204d76e9ce5df98ca6a14b0e8369f91777798426 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Sat, 22 Aug 2026 16:26:22 +0700 Subject: [PATCH 3/5] feat(tui): interactive config screen with current values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config on a TTY now opens a grouped settings screen instead of the flat action menu: Account (identity from /v1/me, api key), Model (default + haiku/sonnet/opus/fable slots), Agent, General (auto-update, channel) — every row showing its live value. Enter edits via the existing pickers (account switch/add/reauth/logout, keys use, model picker, agent picker, toggles); x resets a row to its built-in default; esc closes and the frame re-renders fresh. CreditsCache now also caches identity so the launcher header shows username · email once per TTL. Dump mode stays offline-deterministic; wasm builds keep the legacy menu. Isolate upgrade_check_flag_is_known from a real ~/.anyrouter whose channel skewed it. Co-Authored-By: Claude Fable 5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 570 ++++++++++++++++++++++++++++++++++++++++++++++-- src/help.rs | 15 +- tests/cli.rs | 78 ++++++- 3 files changed, 625 insertions(+), 38 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 788949e..7d04513 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -65,6 +65,27 @@ fn tui_dump_menu( crate::tui::dump_menu_select(title, header, items, crate::tui::dump_cols(env)) } +#[cfg(feature = "native")] +fn tui_settings_select( + state: crate::tui::SettingsState, +) -> Result, String> { + if !crate::tui::is_interactive() { + return Ok(None); + } + match crate::tui::run_settings_live(state)? { + outcome + @ (crate::tui::SettingsOutcome::Edit(_) | crate::tui::SettingsOutcome::Reset(_)) => { + Ok(Some(outcome)) + } + crate::tui::SettingsOutcome::Close | crate::tui::SettingsOutcome::Stay => Ok(None), + } +} + +#[cfg(feature = "native")] +fn tui_dump_settings(state: crate::tui::SettingsState, env: &BTreeMap) -> String { + crate::tui::dump_settings(&state, crate::tui::dump_cols(env)) +} + #[cfg(not(feature = "native"))] fn tui_dump_menu( title: &str, @@ -995,6 +1016,19 @@ fn print_config_status( Ok(()) } +/// Which edit a focused settings row triggers. +#[cfg_attr(not(feature = "native"), allow(dead_code))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettingKind { + Account, + ApiKey, + /// Model slot: "default", "haiku", "sonnet", "opus", "fable". + Model(&'static str), + Agent, + AutoUpdate, + Channel, +} + fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result { let path = config_path(parsed, env); if !tui_wants_dump(parsed, env) && stored_api_key(parsed, env, &path).is_none() { @@ -1005,6 +1039,442 @@ fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result )); } } + #[cfg(feature = "native")] + { + if tui_wants_dump(parsed, env) { + let (state, _) = + config_settings_frame(parsed, env, &path, false, &mut CreditsCache::fresh()); + print!("{}", tui_dump_settings(state, env)); + return Ok(0); + } + config_settings_loop(parsed, env, &path) + } + #[cfg(not(feature = "native"))] + config_menu_loop_legacy(parsed, env, &path) +} + +/// Build one settings frame: grouped rows with current values, plus a parallel +/// list mapping row index → edit kind. `online` gates network (dump stays +/// offline-deterministic). +#[cfg(feature = "native")] +fn config_settings_frame( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, + online: bool, + cache: &mut CreditsCache, +) -> (crate::tui::SettingsState, Vec>) { + use crate::tui::{SettingRow, SettingsState, Tone}; + + let cfg = load_config_if_present(path).unwrap_or_default(); + let profile = cfg.profiles.get(&cfg.active_profile); + let key = stored_api_key(parsed, env, path); + let base = resolve_base_url(&parsed.flags, profile); + let signed_in = key.is_some(); + + let identity = if online { + cache.identity(&base, key.as_deref()) + } else { + None + }; + let account_value = match (&identity, signed_in) { + (Some(me), _) => me.display_label(), + (None, true) => cfg.active_profile.clone(), + (None, false) => "(not signed in)".into(), + }; + let account_tone = if signed_in { Tone::Normal } else { Tone::Warn }; + let credits_value = if !online { + "-".into() + } else { + match identity.as_ref().and_then(|me| me.balance) { + Some(b) => crate::http::format_usd(b), + None => cache.get(&base, key.as_deref()), + } + }; + + let header = vec![ + format!("account {account_value}"), + format!("credits {credits_value}"), + format!("file {}", path.display()), + ]; + + let mut rows: Vec = Vec::new(); + let mut kinds: Vec> = Vec::new(); + fn section(rows: &mut Vec, kinds: &mut Vec>, name: &str) { + rows.push(SettingRow::Section(name.into())); + kinds.push(None); + } + fn entry( + rows: &mut Vec, + kinds: &mut Vec>, + label: &str, + value: String, + tone: Tone, + kind: SettingKind, + ) { + rows.push(SettingRow::Entry { + label: label.into(), + value, + tone, + }); + kinds.push(Some(kind)); + } + + section(&mut rows, &mut kinds, "Account"); + entry( + &mut rows, + &mut kinds, + "account", + account_value.clone(), + account_tone, + SettingKind::Account, + ); + let key_value = if signed_in { + mask_api_key(key.as_deref()) + } else { + "(not set)".into() + }; + entry( + &mut rows, + &mut kinds, + "api key", + key_value, + if signed_in { Tone::Normal } else { Tone::Muted }, + SettingKind::ApiKey, + ); + + section(&mut rows, &mut kinds, "Model"); + entry( + &mut rows, + &mut kinds, + "default", + display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto")).into(), + Tone::Model, + SettingKind::Model("default"), + ); + for (label, slot) in [ + ("haiku", "haiku"), + ("sonnet", "sonnet"), + ("opus", "opus"), + ("fable", "fable"), + ] { + let pinned = match slot { + "haiku" => nonempty_slot(&profile.and_then(|p| p.claude_haiku.clone())), + "sonnet" => nonempty_slot(&profile.and_then(|p| p.claude_sonnet.clone())), + "opus" => nonempty_slot(&profile.and_then(|p| p.claude_opus.clone())), + _ => nonempty_slot(&profile.and_then(|p| p.claude_fable.clone())), + }; + let value = match &pinned { + Some(id) => display_model_id(id).to_string(), + None => format!("{} · default", slot_current_opt(profile, slot)), + }; + let tone = if pinned.is_some() { + Tone::Model + } else { + Tone::Muted + }; + let slot_static: &'static str = match slot { + "haiku" => "haiku", + "sonnet" => "sonnet", + "opus" => "opus", + _ => "fable", + }; + entry( + &mut rows, + &mut kinds, + label, + value, + tone, + SettingKind::Model(slot_static), + ); + } + + section(&mut rows, &mut kinds, "Agent"); + let agent = launcher_last_tool(path, parsed, env); + let agent_pinned = profile.and_then(|p| p.default_tool.clone()).is_some(); + entry( + &mut rows, + &mut kinds, + "coding agent", + agent, + if agent_pinned { + Tone::Normal + } else { + Tone::Muted + }, + SettingKind::Agent, + ); + + section(&mut rows, &mut kinds, "General"); + entry( + &mut rows, + &mut kinds, + "auto-update", + if cfg.auto_update() { + "enabled".into() + } else { + "disabled".into() + }, + if cfg.auto_update() { + Tone::Good + } else { + Tone::Muted + }, + SettingKind::AutoUpdate, + ); + entry( + &mut rows, + &mut kinds, + "update channel", + cfg.channel().into(), + Tone::Normal, + SettingKind::Channel, + ); + + (SettingsState::new("Config", header, rows), kinds) +} + +#[cfg_attr(not(feature = "native"), allow(dead_code))] +fn nonempty_slot(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Effective slot value for display when nothing is pinned. +#[cfg_attr(not(feature = "native"), allow(dead_code))] +fn slot_current_opt(profile: Option<&Profile>, slot: &str) -> String { + slot_current(profile.unwrap_or(&Profile::default()), slot).to_string() +} + +/// Settings loop: render → edit/reset → re-render with fresh values. +#[cfg(feature = "native")] +fn config_settings_loop( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, +) -> Result { + let mut cache = CreditsCache::fresh(); + loop { + let (state, kinds) = config_settings_frame(parsed, env, path, true, &mut cache); + let Some(outcome) = tui_settings_select(state)? else { + return Ok(0); + }; + let result = match outcome { + crate::tui::SettingsOutcome::Edit(idx) => kinds + .get(idx) + .copied() + .flatten() + .map(|kind| config_edit_row(parsed, env, path, kind)), + crate::tui::SettingsOutcome::Reset(idx) => kinds + .get(idx) + .copied() + .flatten() + .map(|kind| config_reset_row(path, kind)), + crate::tui::SettingsOutcome::Close | crate::tui::SettingsOutcome::Stay => None, + }; + if let Some(Err(err)) = result { + eprintln!("{}", term::err(&err)); + } + } +} + +#[cfg_attr(not(feature = "native"), allow(dead_code))] +fn config_edit_row( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, + kind: SettingKind, +) -> Result { + match kind { + SettingKind::Account => config_account_actions(parsed, env), + SettingKind::ApiKey => { + let mut next = parsed.clone(); + next.command = "keys".into(); + next.passthrough = vec!["use".into()]; + run_keys(&next, env) + } + SettingKind::Model(slot) => { + let existing = load_config_if_present(path); + let profile = existing + .as_ref() + .and_then(|c| c.profiles.get(&c.active_profile)); + let key = resolve_api_key(&parsed.flags, env, profile); + let base = resolve_base_url(&parsed.flags, profile); + let models = fetch_models(&base, key.as_deref())?; + let current = profile.map(|p| slot_current(p, slot).to_string()); + let id = pick_model(&models, current.as_deref(), slot_title(slot))?; + save_model_slot(existing, path, slot, &id) + } + SettingKind::Agent => { + let last = launcher_last_tool(path, parsed, env); + let labels: Vec = LAUNCH_AGENTS + .iter() + .map(|(id, label)| format!("{id} — {label}")) + .collect(); + let current = LAUNCH_AGENTS + .iter() + .position(|(id, _)| *id == last.as_str()); + let idx = term::pick("Coding agent", &labels, current)?; + let (tool, label) = LAUNCH_AGENTS[idx]; + let mut cfg = load_config_if_present(path).unwrap_or_default(); + if let Some(p) = cfg.profiles.get_mut(&cfg.active_profile) { + p.default_tool = Some(tool.into()); + } + write_config(&cfg, path)?; + println!( + "{} coding agent {}", + term::ok("Saved"), + term::paint(term::tool_color(tool), label) + ); + Ok(0) + } + SettingKind::AutoUpdate => { + let mut cfg = load_config_if_present(path).unwrap_or_default(); + cfg.auto_update = Some(!cfg.auto_update()); + write_config(&cfg, path)?; + println!( + "{} auto-update {}", + term::ok("Saved"), + if cfg.auto_update() { + "enabled" + } else { + "disabled" + } + ); + Ok(0) + } + SettingKind::Channel => { + let choices = ["stable", "beta"]; + let current = choices.iter().position(|c| *c == cfg_channel(path)); + let idx = term::pick( + "Update channel", + &choices.iter().map(|c| c.to_string()).collect::>(), + current, + )?; + let mut cfg = load_config_if_present(path).unwrap_or_default(); + cfg.channel = Some(choices[idx].into()); + write_config(&cfg, path)?; + println!("{} channel {}", term::ok("Saved"), choices[idx]); + Ok(0) + } + } +} + +#[cfg_attr(not(feature = "native"), allow(dead_code))] +fn cfg_channel(path: &std::path::Path) -> String { + load_config_if_present(path) + .map(|c| c.channel().to_string()) + .unwrap_or_else(|| "stable".into()) +} + +#[cfg_attr(not(feature = "native"), allow(dead_code))] +fn config_account_actions( + parsed: &ParsedArgs, + env: &BTreeMap, +) -> Result { + let actions = [ + "Switch account", + "Add account", + "Re-authenticate (login)", + "Log out", + ]; + let idx = term::pick( + "Account", + &actions.iter().map(|s| s.to_string()).collect::>(), + Some(0), + )?; + 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) + } + 2 => run_login(parsed, env), + _ => run_logout(parsed, env), + } +} + +/// `x` on a row: clear the override so the built-in default applies again. +#[cfg_attr(not(feature = "native"), allow(dead_code))] +fn config_reset_row(path: &std::path::Path, kind: SettingKind) -> Result { + let mut cfg = load_config_if_present(path).unwrap_or_default(); + match kind { + SettingKind::AutoUpdate => { + if cfg.auto_update.is_none() { + println!("{}", term::dim("auto-update already at default (enabled)")); + return Ok(0); + } + cfg.auto_update = None; + write_config(&cfg, path)?; + println!( + "{} auto-update reset to default (enabled)", + term::ok("Saved") + ); + Ok(0) + } + SettingKind::Channel => { + if cfg.channel.is_none() { + println!("{}", term::dim("channel already at default (stable)")); + return Ok(0); + } + cfg.channel = None; + write_config(&cfg, path)?; + println!("{} channel reset to default (stable)", term::ok("Saved")); + Ok(0) + } + SettingKind::Account | SettingKind::ApiKey => Ok(0), + SettingKind::Model(_) | SettingKind::Agent => { + let name = cfg.active_profile.clone(); + let Some(p) = cfg.profiles.get_mut(&name) else { + return Err(no_key_error()); + }; + let (label, was_set) = match kind { + SettingKind::Model("haiku") => ("haiku", p.claude_haiku.take().is_some()), + SettingKind::Model("sonnet") => ("sonnet", p.claude_sonnet.take().is_some()), + SettingKind::Model("opus") => ("opus", p.claude_opus.take().is_some()), + SettingKind::Model("fable") => ("fable", p.claude_fable.take().is_some()), + SettingKind::Model(_) => ("default model", p.default_model.take().is_some()), + _ => ("coding agent", p.default_tool.take().is_some()), + }; + if !was_set { + println!("{}", term::dim(&format!("{label} already at default"))); + return Ok(0); + } + write_config(&cfg, path)?; + println!("{} {} reset to default", term::ok("Saved"), label); + Ok(0) + } + } +} + +/// Non-native builds (wasm demo) keep the flat action menu. +#[cfg(not(feature = "native"))] +fn config_menu_loop_legacy( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, +) -> Result { let items = vec![ "Switch key".into(), "Switch account".into(), @@ -1014,13 +1484,8 @@ fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result "Log out".into(), "Done".into(), ]; - if tui_wants_dump(parsed, env) { - let header = config_tui_header(&path); - print!("{}", tui_dump_menu("Config", header, items, env)); - return Ok(0); - } loop { - let header = config_tui_header(&path); + let header = config_tui_header(path); let Some(idx) = tui_menu_select("Config", header, items.clone())? else { return Ok(0); }; @@ -1054,6 +1519,7 @@ fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result } } +#[cfg(not(feature = "native"))] fn config_tui_header(path: &std::path::Path) -> Vec { let cfg = load_config_if_present(path).unwrap_or_default(); let profile = cfg.profiles.get(&cfg.active_profile); @@ -1661,7 +2127,11 @@ const LAUNCH_AGENTS: &[(&str, &str)] = &[ ("pool", "Poolside"), ]; -fn launcher_last_tool(path: &PathBuf, parsed: &ParsedArgs, env: &BTreeMap) -> String { +fn launcher_last_tool( + path: &PathBuf, + parsed: &ParsedArgs, + env: &BTreeMap, +) -> String { let cfg = load_config_if_present(path).unwrap_or_default(); let profile = cfg.profiles.get(&cfg.active_profile); cfg.last_tool @@ -1678,10 +2148,11 @@ fn launcher_signed_in(path: &PathBuf, parsed: &ParsedArgs, env: &BTreeMap>, + me: Option>, fetched_at: Option, } @@ -1691,32 +2162,61 @@ impl CreditsCache { fn fresh() -> Self { Self { value: None, + me: None, fetched_at: None, } } - /// Return the cached display string, refreshing when stale. - fn get(&mut self, base_url: &str, api_key: Option<&str>) -> String { + /// Refresh both credits and identity when stale. + fn refresh(&mut self, base_url: &str, api_key: Option<&str>) { let expired = self .fetched_at .map(|t| t.elapsed() > CREDITS_TTL) .unwrap_or(true); - if expired { - self.value = match api_key { - Some(key) => Some( - fetch_credits(base_url, key) + if !expired { + return; + } + match api_key { + Some(key) => { + // /v1/me carries email + username + balance in one call; fall + // back to /v1/credits when it is unavailable (older gateway). + self.me = Some(crate::http::fetch_me(base_url, key).map_err(|_| ())); + let from_me = match &self.me { + Some(Ok(me)) => me.balance.map(crate::http::format_usd), + _ => None, + }; + self.value = Some(match from_me { + Some(s) => Ok(s), + None => fetch_credits(base_url, key) .map(|c| crate::http::format_usd(c["balance"].as_f64().unwrap_or(0.0))) .map_err(|_| ()), - ), - None => Some(Err(())), - }; - self.fetched_at = Some(std::time::Instant::now()); + }); + } + None => { + self.me = Some(Err(())); + self.value = Some(Err(())); + } } + self.fetched_at = Some(std::time::Instant::now()); + } + + /// Return the cached credits display string, refreshing when stale. + fn get(&mut self, base_url: &str, api_key: Option<&str>) -> String { + self.refresh(base_url, api_key); match &self.value { Some(Ok(s)) => s.clone(), _ => "(unknown)".into(), } } + + /// Cached identity, refreshing when stale. `None` when unknown. + fn identity(&mut self, base_url: &str, api_key: Option<&str>) -> Option { + self.refresh(base_url, api_key); + match &self.me { + Some(Ok(me)) => Some(me.clone()), + _ => None, + } + } } fn launcher_frame( @@ -1737,7 +2237,7 @@ fn launcher_frame( } else { format!("credits {}", credits.get(&base, key.as_deref())) }; - let header = vec![ + let account_line = if tui_wants_dump(parsed, env) || !term::is_interactive() { format!( "account {} {}", cfg.active_profile, @@ -1746,7 +2246,26 @@ fn launcher_frame( } else { "(not signed in)".into() } - ), + ) + } else { + let identity = credits + .identity(&base, key.as_deref()) + .map(|me| me.display_label()); + match identity { + Some(label) => format!("account {label}"), + None => format!( + "account {} {}", + cfg.active_profile, + if signed_in { + mask_api_key(profile.and_then(|p| p.api_key.as_deref())) + } else { + "(not signed in)".into() + } + ), + } + }; + let header = vec![ + account_line, format!( "model {}", display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto")) @@ -1862,7 +2381,10 @@ fn launch_agent_picker( path: &PathBuf, ) -> Result { if !launcher_signed_in(path, parsed, env) { - eprintln!("{}", term::warn("Not signed in — login first, or pass --key.")); + eprintln!( + "{}", + term::warn("Not signed in — login first, or pass --key.") + ); if let Err(err) = run_login(parsed, env) { eprintln!("{}", term::err(&err)); return Ok(LauncherNext::Continue); @@ -1876,7 +2398,9 @@ fn launch_agent_picker( .iter() .map(|(id, label)| format!("{id} — {label}")) .collect(); - let current = LAUNCH_AGENTS.iter().position(|(id, _)| *id == last.as_str()); + let current = LAUNCH_AGENTS + .iter() + .position(|(id, _)| *id == last.as_str()); let idx = match term::pick("Launch coding agent", &labels, current) { Ok(i) => i, Err(err) if err == "Cancelled." => return Ok(LauncherNext::Continue), diff --git a/src/help.rs b/src/help.rs index 118ae37..3b3afcc 100644 --- a/src/help.rs +++ b/src/help.rs @@ -198,7 +198,7 @@ pub fn command_help(command: &str) -> Option { ), "menu" => fill( &bin, - "{bin} menu — open the centered TUI launcher (default on a TTY)\n\nUsage:\n {bin} Same as `{bin} menu` on a TTY\n {bin} menu [--dump-tui]\n\nA compact dialog: brand + status (account / model / agent / credits),\nthen actions — launch an agent, open Config, sign in, or quit.\n\nKeys: ↑↓ / j k move · ↵ select · q / esc quit\n\nConfig opens the existing settings flow (model, account, key, credits).\n`--dump-tui` / ANYR_TUI_DUMP=1 prints one plain frame (for tests and pipes).\n", + "{bin} menu — open the centered TUI launcher (default on a TTY)\n\nUsage:\n {bin} Same as `{bin} menu` on a TTY\n {bin} menu [--dump-tui]\n\nA compact dialog: brand + status (account / model / agent / credits),\nthen actions — launch an agent, open Config, sign in, or quit.\n\nKeys: ↑↓ / j k move · ↵ select · q / esc quit\n\nConfig opens the grouped settings screen (account, keys, model slots,\nagent, auto-update) with current values on every row.\n`--dump-tui` / ANYR_TUI_DUMP=1 prints one plain frame (for tests and pipes).\n", ), "prompt" => fill( &bin, @@ -345,17 +345,20 @@ Options: "; const CONFIG: &str = "\ -Interactive config: pick key, account, model, and see credits. +Interactive config: accounts, keys, models, agent, credits, updates. USAGE - {bin} config Open the TUI (TTY) + {bin} config Open the settings TUI (TTY) {bin} config get [--json] Print current status {bin} config path Print the config file path {bin} config use Switch the active account -On a TTY, `{bin} config` opens a centered dialog until you pick Done: switch -key, account, model (default / haiku / sonnet / opus / fable), view credits, sign -in, or log out. Also reachable from the launcher via Config. +On a TTY, `{bin} config` opens a grouped settings screen — Account, Model, +Agent, General — each row showing its current value. ↑↓ / j k navigate, +↵ edits the focused row (switch / add / re-auth / log out accounts, pick a +key or model slot, choose the coding agent, toggle auto-update, switch +channel), x resets a row to its default, q / esc closes. +Also reachable from the launcher via Config. `--dump-tui` prints one plain frame and exits. "; diff --git a/tests/cli.rs b/tests/cli.rs index b5256e4..7e52810 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -230,7 +230,10 @@ fn onboard_shortcuts_and_json() { } let (code, stdout, stderr) = run(&["onboard", "plan", "--json"]); assert_eq!(code, 0, "stderr={stderr}"); - assert!(stdout.contains("\"mode\":\"plan\"") || stdout.contains("\"mode\": \"plan\""), "{stdout}"); + assert!( + stdout.contains("\"mode\":\"plan\"") || stdout.contains("\"mode\": \"plan\""), + "{stdout}" + ); assert!( stdout.to_ascii_lowercase().contains("do not change"), "{stdout}" @@ -635,7 +638,13 @@ fn update_stable_and_beta_conflict() { #[test] fn upgrade_check_flag_is_known() { - let fixture = std::env::temp_dir().join("anyr-cli-upgrade-check.json"); + // Isolate from a real ~/.anyrouter whose channel would skew the check. + let home = std::env::temp_dir().join(format!("anyr-cli-upgrade-home-{}", std::process::id())); + std::fs::create_dir_all(&home).unwrap(); + let fixture = std::env::temp_dir().join(format!( + "anyr-cli-upgrade-check-{}.json", + std::process::id() + )); std::fs::write( &fixture, r#"[{"tag_name":"v0.1.0","prerelease":false,"draft":false,"assets":[{"name":"anyr-linux-x86_64"}]}]"#, @@ -644,6 +653,8 @@ fn upgrade_check_flag_is_known() { let out = anyr() .args(["upgrade", "--check"]) .env("ANYR_RELEASES_JSON", &fixture) + .env("ANYROUTER_HOME", &home) + .env_remove("ANYR_CHANNEL") .output() .expect("upgrade --check"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -1038,7 +1049,10 @@ profiles: let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!(out.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); - assert!(!stdout.contains('\u{1b}'), "dump must be ANSI-free: {stdout}"); + assert!( + !stdout.contains('\u{1b}'), + "dump must be ANSI-free: {stdout}" + ); assert!(stdout.contains("▲ AnyRouter"), "{stdout}"); assert!(stdout.contains("Launch"), "{stdout}"); assert!(stdout.contains("Config"), "{stdout}"); @@ -1056,12 +1070,58 @@ profiles: #[test] fn config_dump_tui_prints_plain_frame() { - let (code, stdout, stderr) = run(&["config", "--dump-tui"]); - assert_eq!(code, 0, "{stdout}{stderr}"); - assert!(!stdout.contains('\u{1b}'), "dump must be ANSI-free: {stdout}"); - assert!(stdout.contains("▲ Config"), "{stdout}"); - assert!(stdout.contains("Switch key"), "{stdout}"); - assert!(stdout.contains("Done"), "{stdout}"); + let dir = std::env::temp_dir().join(format!("anyr-cli-config-dump-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.yaml"); + std::fs::write( + &path, + "\ +active_profile: default +profiles: + default: + api_key: sk-ar-v1-config-dump-secret-value-abcdef + default_model: auto +", + ) + .unwrap(); + let out = anyr() + .args(["config", "--dump-tui", "--config", path.to_str().unwrap()]) + .output() + .expect("config dump"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); + assert!( + !stdout.contains('\u{1b}'), + "dump must be ANSI-free: {stdout}" + ); + assert!( + stdout.contains('╭') && stdout.contains('╯'), + "dialog card: {stdout}" + ); + // Grouped sections with current values. + for section in ["ACCOUNT", "MODEL", "AGENT", "GENERAL"] { + assert!(stdout.contains(section), "missing {section} in:\n{stdout}"); + } + for row in [ + "account", + "api key", + "default", + "haiku", + "sonnet", + "opus", + "fable", + "coding agent", + "auto-update", + "update channel", + ] { + assert!(stdout.contains(row), "missing row \"{row}\" in:\n{stdout}"); + } + assert!( + !stdout.contains("config-dump-secret-value"), + "dump must not leak full secret: {stdout}" + ); + let _ = std::fs::remove_dir_all(&dir); } #[test] From e1db91e1893d6bcdb10d4c6376b2cd7802a8a7ad Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Sat, 22 Aug 2026 19:57:39 +0700 Subject: [PATCH 4/5] feat(cli): remember launched model as session default Launching with --model now persists it as the profile's default_model, so a bare `anyr claude` next time starts with the same model. First launch on a fresh machine seeds a profile from what resolved (key, base URL, model) instead of silently skipping persistence. Also makes the launch dry-run tests hermetic via a temp ANYROUTER_HOME: they previously read whatever lived in the developer's real config and passed only by coincidence. Co-Authored-By: Claude Fable 5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 41 ++++++++++++++++++++++---------- tests/cli.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 7d04513..cf5696b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1664,18 +1664,35 @@ fn run_launch( return Ok(0); } let resolved = ensure_tool_installed(tool_name, &command, parsed.flag_true("install"))?; - if let Some(mut cfg) = existing.clone() { - cfg.last_tool = Some(tool_name.to_string()); - if aliases_changed { - if let Some(p) = cfg.profiles.get_mut(&cfg.active_profile) { - p.claude_haiku = profile.claude_haiku.clone(); - p.claude_sonnet = profile.claude_sonnet.clone(); - p.claude_opus = profile.claude_opus.clone(); - p.claude_fable = profile.claude_fable.clone(); - } - } - let _ = write_config(&cfg, &path); - } + // Persist on top of the existing config when there is one; a fresh setup + // (no config yet) gets one so last_tool and the model are remembered. + let mut cfg = existing.clone().unwrap_or_else(|| crate::config::Config { + active_profile: DEFAULT_PROFILE.into(), + ..crate::config::Config::default() + }); + // First launch on this machine: no stored profile yet — seed one from + // what this launch resolved so the key, base URL, and model stick. + cfg.profiles + .entry(cfg.active_profile.clone()) + .or_insert_with(|| profile.clone()); + cfg.last_tool = Some(tool_name.to_string()); + if aliases_changed { + if let Some(p) = cfg.profiles.get_mut(&cfg.active_profile) { + p.claude_haiku = profile.claude_haiku.clone(); + p.claude_sonnet = profile.claude_sonnet.clone(); + p.claude_opus = profile.claude_opus.clone(); + p.claude_fable = profile.claude_fable.clone(); + } + } + // Remember the model this launch used as the session default, so a bare + // `{bin} claude` next time starts with it. + if let Some(flag_model) = get_string_flag(&parsed.flags, "model") { + let id = display_model_id(&flag_model).to_string(); + if let Some(p) = cfg.profiles.get_mut(&cfg.active_profile) { + p.default_model = Some(id); + } + } + let _ = write_config(&cfg, &path); let _updater = crate::upgrade::start_session_checker(env); Ok(spawn_child(&resolved, &args, &env_map)) } diff --git a/tests/cli.rs b/tests/cli.rs index 7e52810..7d7812c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,6 +6,21 @@ fn anyr() -> Command { cmd } +/// Fresh empty ANYROUTER_HOME so launch tests assert on built-in defaults +/// instead of whatever happens to live in the developer's real config. +fn temp_home() -> std::path::PathBuf { + let home = std::env::temp_dir().join(format!( + "anyr-cli-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&home).expect("create temp home"); + home +} + fn run(args: &[&str]) -> (i32, String, String) { let out = anyr().args(args).output().expect("spawn anyr"); ( @@ -285,6 +300,7 @@ fn spawn_targets_dry_run_inject_gateway_and_redact_key() { for (args, marker) in cases { let out = anyr() .args(*args) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("dry-run"); @@ -323,6 +339,7 @@ fn pi_dry_run_uses_anyrouter_provider() { "--config", cfg.to_str().unwrap(), ]) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("pi dry-run"); @@ -371,6 +388,7 @@ fn claude_dry_run_with_key_prints_base_and_redacts_secret() { "--model", "auto", ]) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("dry-run"); @@ -415,6 +433,7 @@ fn claude_dry_run_pinned_model_collapses_aliases() { "--model", "stealth/ox-alpha", ]) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("dry-run"); @@ -457,6 +476,7 @@ fn claude_dry_run_haiku_flag_beats_pinned_model() { "--haiku", "z-ai/glm-4.7-flash", ]) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("dry-run"); @@ -493,6 +513,7 @@ fn claude_dry_run_fable_flag_beats_pinned_model() { "--fable", "anthropic/claude-fable-5", ]) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("dry-run"); @@ -535,6 +556,7 @@ fn claude_dry_run_haiku_flag_overrides_alias() { "--haiku", "z-ai/glm-4.7-flash", ]) + .env("ANYROUTER_HOME", temp_home()) .env_remove("ANYROUTER_API_KEY") .output() .expect("dry-run"); @@ -562,6 +584,47 @@ fn model_without_value_errors() { assert!(format!("{stdout}{stderr}").contains("requires a value")); } +#[test] +fn launch_remembers_explicit_model_as_session_default() { + // Launching with --model must persist it as default_model so a bare + // `{bin} claude` next time starts with the same model. /bin/true stands + // in for claude so the spawn succeeds without the real binary. + let home = temp_home(); + let out = anyr() + .args([ + "claude", + "--yes", + "--key", + "sk-ar-v1-testkey", + "--model", + "z-ai/glm-4.7-flash", + ]) + .env("ANYROUTER_HOME", &home) + .env("ANYROUTER_CLAUDE_PATH", "/bin/true") + .env_remove("ANYROUTER_API_KEY") + .output() + .expect("launch"); + assert_eq!(out.status.code().unwrap_or(1), 0); + + // Second launch with no --model: dry-run reveals which model was picked. + let out = anyr() + .args(["claude", "--yes", "--dry-run", "--key", "sk-ar-v1-testkey"]) + .env("ANYROUTER_HOME", &home) + .env_remove("ANYROUTER_API_KEY") + .output() + .expect("relaunch"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("ANTHROPIC_MODEL=z-ai/glm-4.7-flash"), + "session default not remembered:\n{stdout}" + ); + + // And the persisted config records it too. + let cfg = std::fs::read_to_string(home.join("config.yaml")).expect("config written"); + assert!(cfg.contains("default_model: z-ai/glm-4.7-flash"), "{cfg}"); + let _ = std::fs::remove_dir_all(&home); +} + #[test] fn upgrade_help_mentions_channel_stable_beta() { let (code, stdout, stderr) = run(&["upgrade", "--help"]); From 885f8278ef25c9ab468b3d0301af0ad0073b54d2 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Sun, 23 Aug 2026 05:09:16 +0700 Subject: [PATCH 5/5] fix(tests): use portable spawn stub in remember-model test /bin/true only exists on Linux; CI failed on macOS and Windows. Use `true` (resolved via which) on unix and `cmd` on Windows. Co-Authored-By: Claude Fable 5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- tests/cli.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index 7d7812c..e60dc09 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -587,8 +587,9 @@ fn model_without_value_errors() { #[test] fn launch_remembers_explicit_model_as_session_default() { // Launching with --model must persist it as default_model so a bare - // `{bin} claude` next time starts with the same model. /bin/true stands - // in for claude so the spawn succeeds without the real binary. + // `{bin} claude` next time starts with the same model. A trivially + // successful binary stands in for claude so the spawn works everywhere. + let stub = if cfg!(windows) { "cmd" } else { "true" }; let home = temp_home(); let out = anyr() .args([ @@ -600,7 +601,7 @@ fn launch_remembers_explicit_model_as_session_default() { "z-ai/glm-4.7-flash", ]) .env("ANYROUTER_HOME", &home) - .env("ANYROUTER_CLAUDE_PATH", "/bin/true") + .env("ANYROUTER_CLAUDE_PATH", stub) .env_remove("ANYROUTER_API_KEY") .output() .expect("launch");