diff --git a/src/commands/config_cmds.rs b/src/commands/config_cmds.rs index b5b89a5..0357048 100644 --- a/src/commands/config_cmds.rs +++ b/src/commands/config_cmds.rs @@ -503,19 +503,25 @@ pub(super) async fn cmd_backend(args: &str, state: &mut AppState) -> Result<()> let chosen: Option = if !args.is_empty() { BackendName::parse(args) } else { - println!( - " {DIM}Current:{RESET} {CYAN}{}{RESET}", - state.config.backend.as_str() - ); - for (i, b) in BackendName::all().iter().enumerate() { - println!(" {DIM}{}){RESET} {}", i + 1, b.as_str()); + let backends = BackendName::all(); + let options: Vec = backends + .iter() + .map(|b| { + if *b == state.config.backend { + format!("{} *", b.as_str()) + } else { + b.as_str().to_string() + } + }) + .collect(); + let default_idx = backends + .iter() + .position(|b| *b == state.config.backend) + .unwrap_or(0); + match select_from_list("Backend".into(), options, default_idx).await? { + Some(idx) => backends.get(idx).copied(), + None => None, } - let prompt = format!(" {DIM}Select (1-{}):{RESET} ", BackendName::all().len()); - let pick = plain_read_line(prompt).await?.trim().to_string(); - pick.parse::() - .ok() - .and_then(|n| n.checked_sub(1)) - .and_then(|i| BackendName::all().get(i).copied()) }; let Some(chosen) = chosen else { println!(" {DIM}Cancelled.{RESET}"); @@ -581,6 +587,14 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> { ); return Ok(()); } + + let backend_default = default_model(&state.backend, None); + let current = state + .config + .model_override + .clone() + .unwrap_or_else(|| state.model.clone()); + let mut out = std::io::stdout(); let _ = write!( out, @@ -589,68 +603,100 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> { ); let _ = out.flush(); let ids = match list_models(&state.http, &state.backend).await { - Ok(v) => v, + Ok(v) => { + let _ = write!(out, "\r\x1b[K"); + let _ = out.flush(); + v + } Err(e) => { let _ = write!(out, "\r\x1b[K"); - println!(" {RED}✗{RESET} {DIM}Failed: {e}{RESET}"); - return Ok(()); + let _ = out.flush(); + println!( + " {YELLOW}!{RESET} {DIM}Could not list models ({e}); enter a model id.{RESET}" + ); + return apply_model_free_text(state, &backend_default, Some(current.as_str())).await; } }; - let _ = write!(out, "\r\x1b[K"); - let _ = out.flush(); if ids.is_empty() { - println!(" {DIM}No models available.{RESET}"); + println!(" {YELLOW}!{RESET} {DIM}No models returned; enter a model id.{RESET}"); + return apply_model_free_text(state, &backend_default, Some(current.as_str())).await; + } + + let (mut labels, items, default_idx) = + build_model_menu(ids, &backend_default, Some(current.as_str())); + // Append catalog pricing/context when known (cloud models) so the arrow + // menu keeps the extra detail the old numbered list showed. + for (label, item) in labels.iter_mut().zip(items.iter()) { + if let ModelMenuItem::Id(id) = item { + if let Some(info) = catalog::lookup(state.config.backend, id) { + *label = format!("{label} {}", catalog::format_cost_label(info)); + } + } + } + + let Some(idx) = select_from_list("Model".into(), labels, default_idx).await? else { + println!(" {DIM}Cancelled.{RESET}"); return Ok(()); + }; + match items.get(idx) { + Some(ModelMenuItem::Id(id)) => { + apply_model_choice(state, model_override_for(id, &backend_default)); + } + Some(ModelMenuItem::FreeText) => { + apply_model_free_text(state, &backend_default, Some(current.as_str())).await?; + } + None => println!(" {DIM}Cancelled.{RESET}"), } - let prompt = format!(" {DIM}Filter (blank for all):{RESET} "); - let filter = plain_read_line(prompt).await?.trim().to_lowercase(); - let matches: Vec = if filter.is_empty() { - ids + Ok(()) +} + +async fn apply_model_free_text( + state: &mut AppState, + backend_default: &str, + current: Option<&str>, +) -> Result<()> { + let prompt = if let Some(current) = current { + format!( + " {CYAN}❯{RESET} {DIM}Model id [current: {current}; blank: {backend_default}]:{RESET} " + ) } else { - ids.into_iter() - .filter(|m| m.to_lowercase().contains(&filter)) - .collect() + format!(" {CYAN}❯{RESET} {DIM}Model id [blank: {backend_default}]:{RESET} ") }; - let total = matches.len(); - let shown: Vec = matches.into_iter().take(20).collect(); - if shown.is_empty() { - println!(" {DIM}No matches.{RESET}"); + let input = plain_read_line(prompt).await?; + let trimmed = input.trim(); + if trimmed.is_empty() { + apply_model_choice(state, None); return Ok(()); } - let name_width = shown.iter().map(|m| m.len()).max().unwrap_or(0); - for (i, m) in shown.iter().enumerate() { - match catalog::lookup(state.config.backend, m) { - Some(info) => println!( - " {DIM}{:>2}){RESET} {: println!(" {DIM}{:>2}){RESET} {}", i + 1, m), - } - } - if total > shown.len() { - println!(" {DIM}…and {} more{RESET}", total - shown.len()); + if matches!(trimmed.to_lowercase().as_str(), "q" | "quit" | "cancel") { + println!(" {DIM}Cancelled.{RESET}"); + return Ok(()); } - let prompt = format!(" {DIM}Select (1-{}):{RESET} ", shown.len()); - let pick = plain_read_line(prompt).await?.trim().to_string(); - if let Some(idx) = pick.parse::().ok().and_then(|n| n.checked_sub(1)) { - if let Some(m) = shown.get(idx) { - state.config.model_override = Some(m.clone()); - state.active_effort = None; - state.resolve_model(); + if matches!(state.config.backend, BackendName::OpenAiCodex) { + let Some(canonical) = crate::codex_responses::canonical_codex_model(trimmed) else { println!( - " {GREEN}✓{RESET} {DIM}model →{RESET} {CYAN}{}{RESET}", - state.model + " {RED}✗{RESET} {DIM}{trimmed} is not supported with ChatGPT/Codex login. Try one of: {}{RESET}", + crate::codex_responses::codex_model_list().join(", ") ); return Ok(()); - } + }; + apply_model_choice(state, model_override_for(canonical, backend_default)); + return Ok(()); } - println!(" {DIM}Cancelled.{RESET}"); + apply_model_choice(state, model_override_for(trimmed, backend_default)); Ok(()) } +fn apply_model_choice(state: &mut AppState, model_override: Option) { + state.config.model_override = model_override; + state.active_effort = None; + state.resolve_model(); + println!( + " {GREEN}✓{RESET} {DIM}model →{RESET} {CYAN}{}{RESET}", + state.model + ); +} + pub(super) fn cmd_tools(args: &str, state: &mut AppState) { if args.is_empty() { println!(" {DIM}available{RESET} {}", ALL_TOOL_NAMES.join(", ")); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2607a2f..fd3f996 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -38,7 +38,7 @@ use crate::handoff::{ render_handoff_prompt, should_refuse_cloud_handoff, }; use crate::hardware::{detect_hardware_spec, save_hardware_summary, HardwareSpec}; -use crate::input::plain_read_line; +use crate::input::{plain_read_line, select_from_list}; use crate::iterate_loop::{collect_diff_context, parse_iterate_args, run_iterate_loop}; use crate::model_system::{EffortLevel, ModelRef, TaskComplexity}; use crate::openai::{ @@ -72,6 +72,7 @@ use crate::session::{ }; use crate::session_paths::{apply_path_session_state, PathStore, DEFAULT_PATH_ID}; use crate::session_turn::{run_user_turn, TurnOptions}; +use crate::setup::{build_model_menu, model_override_for, ModelMenuItem}; use crate::shipcheck::{ collect_shipcheck, collect_shipcheck_with_tests, default_export_path, file_status_label, render_markdown as render_shipcheck_markdown, ShipcheckSnapshot, diff --git a/src/input.rs b/src/input.rs index 06ba272..e116ee2 100644 --- a/src/input.rs +++ b/src/input.rs @@ -5,7 +5,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::Path; -use crate::theme::{ACCENT, BOLD, MUTED, RESET}; +use crate::theme::{ACCENT, BOLD, MUTED, PAD, POINT, RESET}; #[derive(Debug, Clone, Serialize, Deserialize)] struct HistoryEntry { @@ -354,7 +354,9 @@ fn read_plain_outcome( } if let Some(outcome) = control_key_outcome(code, modifiers) { redraw(&mut out, &chars, cursor, sel, true)?; - writeln!(out)?; + // Raw mode: LF alone stays on the same column; CR+LF + // parks the cursor at column 0 of the next line. + write!(out, "\r\n")?; out.flush()?; return Ok(outcome); } @@ -362,7 +364,10 @@ fn read_plain_outcome( KeyCode::Enter => { // Clear any open menu, then drop to the next line. redraw(&mut out, &chars, cursor, sel, true)?; - writeln!(out)?; + // Raw mode: LF alone stays on the same column; CR+LF + // parks the cursor at column 0 of the next line so + // subsequent UI (e.g. select menus) is left-aligned. + write!(out, "\r\n")?; out.flush()?; return Ok(ReadLineOutcome::Line(chars.iter().collect())); } @@ -479,6 +484,223 @@ fn control_key_outcome(code: KeyCode, modifiers: KeyModifiers) -> Option, + default_idx: usize, +) -> Result> { + if options.is_empty() { + return Ok(None); + } + let outcome = + tokio::task::spawn_blocking(move || read_select_outcome(&title, &options, default_idx)) + .await??; + match outcome { + SelectOutcome::Selected(i) => Ok(Some(i)), + SelectOutcome::Cancelled => Ok(None), + SelectOutcome::Interrupted => std::process::exit(0), + SelectOutcome::Eof => Err(anyhow!("input closed")), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SelectOutcome { + Selected(usize), + Cancelled, + Interrupted, + Eof, +} + +/// Visible option rows in a select menu before it starts scrolling. Long +/// lists (e.g. OpenRouter /models) stay usable without flooding the terminal. +const SELECT_MAX_ROWS: usize = 12; + +/// Pure frame for the select menu. Returns the bytes to write and the number of +/// lines drawn (so the interactive loop can move the cursor back up to redraw). +fn render_select_menu(title: &str, options: &[String], selected: usize) -> (String, usize) { + let n = options.len(); + let selected = if n == 0 { 0 } else { selected.min(n - 1) }; + let mut s = String::new(); + let mut rows = 0usize; + + s.push_str(&format!("{PAD}{BOLD}{title}{RESET}\r\n")); + rows += 1; + + let start = if n <= SELECT_MAX_ROWS || selected < SELECT_MAX_ROWS { + 0 + } else { + // Keep the highlighted row inside the window as the user arrows past + // the first page (same strategy as the slash-command completion menu). + selected + 1 - SELECT_MAX_ROWS + }; + let shown = (n - start).min(SELECT_MAX_ROWS); + + if start > 0 { + s.push_str(&format!("{PAD}{MUTED}… {start} above{RESET}\r\n")); + rows += 1; + } + + for (offset, label) in options.iter().skip(start).take(shown).enumerate() { + let i = start + offset; + let num = i + 1; + if i == selected { + s.push_str(&format!( + "{PAD}{ACCENT}{POINT} {BOLD}{num}) {label}{RESET}\r\n" + )); + } else { + s.push_str(&format!("{PAD} {MUTED}{num}){RESET} {label}\r\n")); + } + rows += 1; + } + + if start + shown < n { + s.push_str(&format!( + "{PAD}{MUTED}… +{} more{RESET}\r\n", + n - start - shown + )); + rows += 1; + } + + s.push_str(&format!( + "{PAD}{MUTED}↑/↓ move · Enter select · 1-9 jump · q cancel{RESET}" + )); + rows += 1; + (s, rows) +} + +fn read_select_outcome( + title: &str, + options: &[String], + default_idx: usize, +) -> Result { + let n = options.len(); + if n == 0 { + return Ok(SelectOutcome::Cancelled); + } + let mut selected = default_idx.min(n - 1); + let mut out = std::io::stdout(); + + crossterm::terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut first = true; + // Track the previous frame height: overflow markers can add/remove a + // line as the window scrolls, so we cannot assume a fixed row count. + let mut prev_rows = 0usize; + loop { + let (frame, rows) = render_select_menu(title, options, selected); + if !first { + // Cursor sits on the last drawn line (hint has no trailing + // newline), so go up `prev_rows - 1` to the title, then clear. + let up = prev_rows.saturating_sub(1); + if up > 0 { + write!(out, "\x1b[{up}A")?; + } + write!(out, "\r\x1b[0J")?; + } else { + // Always start the title at column 0. Callers may leave the + // cursor mid-line (e.g. raw-mode LF after the prompt), which + // made the bold title jump left on the first arrow redraw. + write!(out, "\r")?; + } + first = false; + write!(out, "{frame}")?; + out.flush()?; + prev_rows = rows; + + loop { + let Event::Key(KeyEvent { + code, + modifiers, + kind, + .. + }) = crossterm::event::read()? + else { + continue; + }; + if kind == KeyEventKind::Release { + continue; + } + if let Some(ctrl) = control_key_outcome(code, modifiers) { + // Park the cursor on the next line so the next println + // doesn't overwrite the menu. + write!(out, "\r\n")?; + out.flush()?; + return Ok(match ctrl { + ReadLineOutcome::Interrupted => SelectOutcome::Interrupted, + ReadLineOutcome::Eof => SelectOutcome::Eof, + ReadLineOutcome::Line(_) => unreachable!(), + }); + } + match code { + KeyCode::Up | KeyCode::Char('k') => { + selected = selected.saturating_sub(1); + break; + } + KeyCode::Down | KeyCode::Char('j') => { + selected = (selected + 1).min(n - 1); + break; + } + KeyCode::Home => { + selected = 0; + break; + } + KeyCode::End => { + selected = n - 1; + break; + } + KeyCode::Enter => { + // Final paint so the confirmed row stays highlighted, + // then drop to the next line for subsequent output. + let (frame, _rows) = render_select_menu(title, options, selected); + let up = prev_rows.saturating_sub(1); + if up > 0 { + write!(out, "\x1b[{up}A")?; + } + write!(out, "\r\x1b[0J{frame}\r\n")?; + out.flush()?; + return Ok(SelectOutcome::Selected(selected)); + } + KeyCode::Esc => { + write!(out, "\r\n")?; + out.flush()?; + return Ok(SelectOutcome::Cancelled); + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + write!(out, "\r\n")?; + out.flush()?; + return Ok(SelectOutcome::Cancelled); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + let digit = c.to_digit(10).unwrap_or(0) as usize; + // Single-digit jump only (same as the original wizard). + // Lists with 10+ items use arrows past item 9. + if (1..=n.min(9)).contains(&digit) { + selected = digit - 1; + // Number jump confirms immediately (power-user path + // matching the old "type a number + Enter" flow). + let (frame, _rows) = render_select_menu(title, options, selected); + let up = prev_rows.saturating_sub(1); + if up > 0 { + write!(out, "\x1b[{up}A")?; + } + write!(out, "\r\x1b[0J{frame}\r\n")?; + out.flush()?; + return Ok(SelectOutcome::Selected(selected)); + } + } + _ => {} + } + } + } + })(); + crossterm::terminal::disable_raw_mode()?; + result +} + #[cfg(test)] mod tests { use super::*; @@ -603,4 +825,84 @@ mod tests { None ); } + + #[test] + fn select_menu_highlights_selected_row_and_counts_lines() { + let options = vec!["ollama".into(), "openai *".into(), "openrouter".into()]; + let (frame, rows) = render_select_menu("Backend", &options, 1); + assert_eq!(rows, 5, "title + 3 options + hint"); + assert!(frame.contains("Backend"), "title missing: {frame:?}"); + assert!(frame.contains("▸"), "selected marker missing: {frame:?}"); + // Title and rows share the left gutter (PAD). The interactive loop also + // CR's to column 0 before the first paint so this indent is stable. + assert!( + frame.starts_with(PAD), + "title must start at the left gutter: {frame:?}" + ); + let first_option = frame.lines().nth(1).expect("first option"); + assert!( + first_option.starts_with(PAD), + "options must share the title gutter: {first_option:?}" + ); + // Selected row keeps number+label contiguous (bold). Unselected rows + // insert a RESET between the muted number and the label. + assert!( + frame.contains("2) openai *"), + "selected row missing: {frame:?}" + ); + assert!(frame.contains("ollama"), "row 1 label missing: {frame:?}"); + assert!( + frame.contains("openrouter"), + "row 3 label missing: {frame:?}" + ); + assert!(frame.contains("1)"), "row 1 number missing: {frame:?}"); + assert!(frame.contains("3)"), "row 3 number missing: {frame:?}"); + assert!(frame.contains("↑/↓ move"), "hint missing: {frame:?}"); + let sel_pos = frame.find("2) openai *").expect("selected label"); + let pointer_pos = frame.find('▸').expect("pointer"); + assert!( + pointer_pos < sel_pos, + "pointer should precede selected label" + ); + } + + #[test] + fn select_menu_clamps_selected_index() { + let options = vec!["a".into(), "b".into()]; + let (frame, rows) = render_select_menu("Pick", &options, 99); + assert_eq!(rows, 4, "title + 2 options + hint"); + // Out-of-range selection clamps to last item (index 1 → "2) b"). + let pointer = frame.find('▸').expect("pointer"); + let b_pos = frame.find("2) b").expect("b row"); + let a_pos = frame.find('a').expect("a label"); + assert!( + a_pos < pointer && pointer < b_pos, + "pointer should sit on the last row when clamped: {frame:?}" + ); + } + + #[test] + fn select_menu_scrolls_long_lists() { + let options: Vec = (1..=20).map(|i| format!("model-{i:02}")).collect(); + let (frame, rows) = render_select_menu("Model", &options, 15); + // title + "… above" + 12 options + "… more" + hint + assert_eq!(rows, 16, "windowed frame height: {frame:?}"); + assert!( + frame.contains("… 4 above"), + "top overflow missing: {frame:?}" + ); + assert!( + frame.contains("… +4 more"), + "bottom overflow missing: {frame:?}" + ); + assert!( + frame.contains("16) model-16"), + "selected row should be visible: {frame:?}" + ); + assert!( + !frame.contains("model-01"), + "first page should scroll out: {frame:?}" + ); + assert!(frame.contains("▸"), "selected marker missing: {frame:?}"); + } } diff --git a/src/setup.rs b/src/setup.rs index d5fc8ec..6fa9b7c 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -6,7 +6,7 @@ use std::time::Duration; use crate::backends::{backend, default_model, validate, BackendName}; use crate::config::{dotenv_values, layered_env, AgentConfig, ApprovalPolicy, ToolSelection}; use crate::hardware::{detect_hardware_spec, save_hardware_summary}; -use crate::input::plain_read_line; +use crate::input::{plain_read_line, select_from_list}; use crate::openai::{build_http_client, chat_oneshot, list_models, ChatMessage, ChatRequest}; // Routed through the shared theme so the wizard matches the rest of the TUI @@ -51,7 +51,7 @@ pub async fn run_setup_wizard(base: &AgentConfig) -> Result> println!("{pad}{CYAN}{BOLD}Small Harness setup{RESET}"); println!("{}", crate::theme::rule()); println!( - "{pad}{DIM}A few quick questions — I'll write {CONFIG_PATH}. Press Enter to keep the\n{pad}shown default ({CYAN}*{RESET}{DIM}); type q to cancel.{RESET}" + "{pad}{DIM}A few quick questions — I'll write {CONFIG_PATH}. Use ↑/↓ and Enter\n{pad}(or a number); defaults are marked {CYAN}*{RESET}{DIM}. Type q to cancel.{RESET}" ); println!(); @@ -65,7 +65,12 @@ pub async fn run_setup_wizard(base: &AgentConfig) -> Result> prompt_api_key(chosen_backend).await?; let model_default = default_model(&backend(chosen_backend), None); - let Some(model_override) = prompt_model(&model_default, base.model_override.as_deref()).await? + let Some(model_override) = prompt_model( + chosen_backend, + &model_default, + base.model_override.as_deref(), + ) + .await? else { println!(" {DIM}Setup cancelled.{RESET}"); return Ok(None); @@ -133,46 +138,22 @@ fn setup_config_value(config: &AgentConfig) -> Value { } async fn prompt_backend(default: BackendName) -> Result> { - loop { - println!(" {BOLD}Backend{RESET}"); - for (idx, backend) in BackendName::all().iter().enumerate() { - let marker = if *backend == default { " *" } else { "" }; - println!( - " {DIM}{}){RESET} {}{}", - idx + 1, - backend.as_str(), - marker - ); - } - let default_idx = BackendName::all() - .iter() - .position(|b| *b == default) - .map(|i| i + 1) - .unwrap_or(1); - let input = plain_read_line(format!( - " {CYAN}❯{RESET} {DIM}Select backend [{default_idx}]:{RESET} " - )) - .await?; - let trimmed = input.trim().to_lowercase(); - if is_cancel(&trimmed) { - return Ok(None); - } - if trimmed.is_empty() { - return Ok(Some(default)); - } - if let Some(parsed) = BackendName::parse(&trimmed) { - return Ok(Some(parsed)); - } - if let Some(parsed) = trimmed - .parse::() - .ok() - .and_then(|n| n.checked_sub(1)) - .and_then(|idx| BackendName::all().get(idx).copied()) - { - return Ok(Some(parsed)); - } - println!(" {YELLOW}!{RESET} {DIM}Unknown backend: {trimmed}{RESET}"); - } + let backends = BackendName::all(); + let options: Vec = backends + .iter() + .map(|b| { + if *b == default { + format!("{} *", b.as_str()) + } else { + b.as_str().to_string() + } + }) + .collect(); + let default_idx = backends.iter().position(|b| *b == default).unwrap_or(0); + let Some(idx) = select_from_list("Backend".into(), options, default_idx).await? else { + return Ok(None); + }; + Ok(backends.get(idx).copied()) } /// For a cloud backend, make sure an API key is available. If one is already @@ -238,113 +219,196 @@ async fn prompt_api_key(chosen: BackendName) -> Result<()> { Ok(()) } -async fn prompt_model( +/// Last menu entry: user types a custom model id after selecting it. +pub(crate) const MODEL_FREE_TEXT_LABEL: &str = "type a model id…"; + +/// Build the model menu labels + pick values from a `/models` response. +/// +/// Ensures `default_model` and any current override appear even if the backend +/// omitted them, sorts for scanability, marks the preferred row with `*`, and +/// always appends a free-text entry. +/// +/// Shared by the setup wizard and the interactive `/model` command. +pub(crate) fn build_model_menu( + fetched: Vec, + default_model: &str, + current_override: Option<&str>, +) -> (Vec, Vec, usize) { + let mut ids = Vec::new(); + for id in fetched { + let trimmed = id.trim(); + if trimmed.is_empty() { + continue; + } + if !ids.iter().any(|existing: &String| existing == trimmed) { + ids.push(trimmed.to_string()); + } + } + for extra in [Some(default_model), current_override] + .into_iter() + .flatten() + { + let trimmed = extra.trim(); + if trimmed.is_empty() { + continue; + } + if !ids.iter().any(|existing| existing == trimmed) { + ids.push(trimmed.to_string()); + } + } + ids.sort(); + + let preferred = current_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(default_model); + let default_idx = ids.iter().position(|m| m == preferred).unwrap_or(0); + + let mut labels = Vec::with_capacity(ids.len() + 1); + let mut items = Vec::with_capacity(ids.len() + 1); + for id in &ids { + let label = if id == preferred { + format!("{id} *") + } else { + id.clone() + }; + labels.push(label); + items.push(ModelMenuItem::Id(id.clone())); + } + labels.push(MODEL_FREE_TEXT_LABEL.into()); + items.push(ModelMenuItem::FreeText); + + (labels, items, default_idx) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ModelMenuItem { + Id(String), + FreeText, +} + +/// Resolve a chosen model id into a config override. Matching the backend +/// default stores no override so `agent.config.json` stays clean. +pub(crate) fn model_override_for(chosen: &str, default_model: &str) -> Option { + let trimmed = chosen.trim(); + if trimmed.is_empty() || trimmed == default_model { + None + } else { + Some(trimmed.to_string()) + } +} + +async fn prompt_model_free_text( default_model: &str, current_override: Option<&str>, ) -> Result>> { - println!(" {BOLD}Model{RESET}"); let prompt = if let Some(current) = current_override { - format!(" {CYAN}❯{RESET} {DIM}Model override [current: {current}; blank: {default_model}]:{RESET} ") + format!( + " {CYAN}❯{RESET} {DIM}Model id [current: {current}; blank: {default_model}]:{RESET} " + ) } else { - format!(" {CYAN}❯{RESET} {DIM}Model override [blank: {default_model}]:{RESET} ") + format!(" {CYAN}❯{RESET} {DIM}Model id [blank: {default_model}]:{RESET} ") }; let input = plain_read_line(prompt).await?; let trimmed = input.trim(); if is_cancel(trimmed) { return Ok(None); } - if trimmed.is_empty() { - Ok(Some(None)) - } else { - Ok(Some(Some(trimmed.to_string()))) + Ok(Some(model_override_for(trimmed, default_model))) +} + +async fn prompt_model( + chosen_backend: BackendName, + default_model: &str, + current_override: Option<&str>, +) -> Result>> { + use std::io::Write; + + let backend_desc = backend(chosen_backend); + let http = build_http_client(); + + let mut out = std::io::stdout(); + let _ = write!( + out, + " {DIM}Fetching models from {}…{RESET}", + chosen_backend.as_str() + ); + let _ = out.flush(); + + let fetched = match with_probe_timeout(list_models(&http, &backend_desc)).await { + Ok(models) => { + let _ = write!(out, "\r\x1b[K"); + let _ = out.flush(); + models + } + Err(e) => { + let _ = write!(out, "\r\x1b[K"); + let _ = out.flush(); + println!( + " {YELLOW}!{RESET} {DIM}Could not list models ({e}); enter a model id.{RESET}" + ); + return prompt_model_free_text(default_model, current_override).await; + } + }; + + if fetched.is_empty() { + println!(" {YELLOW}!{RESET} {DIM}No models returned; enter a model id.{RESET}"); + return prompt_model_free_text(default_model, current_override).await; + } + + let (labels, items, default_idx) = build_model_menu(fetched, default_model, current_override); + let Some(idx) = select_from_list("Model".into(), labels, default_idx).await? else { + return Ok(None); + }; + match items.get(idx) { + Some(ModelMenuItem::Id(id)) => Ok(Some(model_override_for(id, default_model))), + Some(ModelMenuItem::FreeText) => { + prompt_model_free_text(default_model, current_override).await + } + None => Ok(None), } } async fn prompt_approval(default: ApprovalPolicy) -> Result> { - let options = [ + let policies = [ ApprovalPolicy::Always, ApprovalPolicy::DangerousOnly, ApprovalPolicy::Never, ]; - loop { - println!(" {BOLD}Approval policy{RESET}"); - for (idx, policy) in options.iter().enumerate() { - let marker = if *policy == default { " *" } else { "" }; - println!(" {DIM}{}){RESET} {}{}", idx + 1, policy.as_str(), marker); - } - let default_idx = options - .iter() - .position(|p| *p == default) - .map(|i| i + 1) - .unwrap_or(1); - let input = plain_read_line(format!( - " {CYAN}❯{RESET} {DIM}Select approval [{default_idx}]:{RESET} " - )) - .await?; - let trimmed = input.trim().to_lowercase(); - if is_cancel(&trimmed) { - return Ok(None); - } - if trimmed.is_empty() { - return Ok(Some(default)); - } - if let Some(parsed) = ApprovalPolicy::parse(&trimmed) { - return Ok(Some(parsed)); - } - if let Some(parsed) = trimmed - .parse::() - .ok() - .and_then(|n| n.checked_sub(1)) - .and_then(|idx| options.get(idx).copied()) - { - return Ok(Some(parsed)); - } - println!(" {YELLOW}!{RESET} {DIM}Unknown approval policy: {trimmed}{RESET}"); - } + let options: Vec = policies + .iter() + .map(|p| { + if *p == default { + format!("{} *", p.as_str()) + } else { + p.as_str().to_string() + } + }) + .collect(); + let default_idx = policies.iter().position(|p| *p == default).unwrap_or(0); + let Some(idx) = select_from_list("Approval policy".into(), options, default_idx).await? else { + return Ok(None); + }; + Ok(policies.get(idx).copied()) } async fn prompt_tool_selection(default: ToolSelection) -> Result> { - let options = [ToolSelection::Auto, ToolSelection::Fixed]; - loop { - println!(" {BOLD}Tool mode{RESET}"); - for (idx, selection) in options.iter().enumerate() { - let marker = if *selection == default { " *" } else { "" }; - println!( - " {DIM}{}){RESET} {}{}", - idx + 1, - selection.as_str(), - marker - ); - } - let default_idx = options - .iter() - .position(|s| *s == default) - .map(|i| i + 1) - .unwrap_or(1); - let input = plain_read_line(format!( - " {CYAN}❯{RESET} {DIM}Select tool mode [{default_idx}]:{RESET} " - )) - .await?; - let trimmed = input.trim().to_lowercase(); - if is_cancel(&trimmed) { - return Ok(None); - } - if trimmed.is_empty() { - return Ok(Some(default)); - } - if let Some(parsed) = ToolSelection::parse(&trimmed) { - return Ok(Some(parsed)); - } - if let Some(parsed) = trimmed - .parse::() - .ok() - .and_then(|n| n.checked_sub(1)) - .and_then(|idx| options.get(idx).copied()) - { - return Ok(Some(parsed)); - } - println!(" {YELLOW}!{RESET} {DIM}Unknown tool mode: {trimmed}{RESET}"); - } + let modes = [ToolSelection::Auto, ToolSelection::Fixed]; + let options: Vec = modes + .iter() + .map(|s| { + if *s == default { + format!("{} *", s.as_str()) + } else { + s.as_str().to_string() + } + }) + .collect(); + let default_idx = modes.iter().position(|s| *s == default).unwrap_or(0); + let Some(idx) = select_from_list("Tool mode".into(), options, default_idx).await? else { + return Ok(None); + }; + Ok(modes.get(idx).copied()) } async fn probe_setup_backend(config: &AgentConfig) { @@ -496,4 +560,58 @@ mod tests { std::env::remove_var(NO_WIZARD_ENV); } } + + #[test] + fn model_menu_lists_fetched_ids_with_free_text_last() { + let (labels, items, default_idx) = build_model_menu( + vec!["zeta".into(), "alpha".into(), "alpha".into()], + "alpha", + None, + ); + assert_eq!( + labels.last().map(String::as_str), + Some(MODEL_FREE_TEXT_LABEL) + ); + assert_eq!(items.last(), Some(&ModelMenuItem::FreeText)); + // Sorted, deduped, default marked, free-text trailing. + assert_eq!( + labels, + vec![ + "alpha *".to_string(), + "zeta".to_string(), + MODEL_FREE_TEXT_LABEL.to_string() + ] + ); + assert_eq!(default_idx, 0); + assert_eq!( + items, + vec![ + ModelMenuItem::Id("alpha".into()), + ModelMenuItem::Id("zeta".into()), + ModelMenuItem::FreeText + ] + ); + } + + #[test] + fn model_menu_injects_default_and_prefers_current_override() { + let (labels, items, default_idx) = + build_model_menu(vec!["other".into()], "backend-default", Some("my-custom")); + assert!(items.contains(&ModelMenuItem::Id("backend-default".into()))); + assert!(items.contains(&ModelMenuItem::Id("my-custom".into()))); + assert!(items.contains(&ModelMenuItem::Id("other".into()))); + assert_eq!(items.last(), Some(&ModelMenuItem::FreeText)); + assert!(labels[default_idx].starts_with("my-custom")); + assert!(labels[default_idx].contains('*')); + } + + #[test] + fn model_override_omits_backend_default() { + assert_eq!(model_override_for("gpt-4o-mini", "gpt-4o-mini"), None); + assert_eq!(model_override_for(" ", "gpt-4o-mini"), None); + assert_eq!( + model_override_for("gpt-4o", "gpt-4o-mini"), + Some("gpt-4o".into()) + ); + } }