diff --git a/crates/codegen/kigi-tui/src/app/dispatch/dashboard.rs b/crates/codegen/kigi-tui/src/app/dispatch/dashboard.rs index a7c95dd..bc3135c 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/dashboard.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/dashboard.rs @@ -1251,6 +1251,19 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) } return vec![]; } + // Required-args command submitted bare (`/model` + Enter): re-open + // the dispatch box in the args phase so the dropdown lists the + // choices — same "Blocks" contract as the session prompt path. + if !crate::slash::is_command_complete(trimmed.as_str(), reg) { + let reopened = format!("/{} ", invocation.token); + let models = app.models.clone(); + if let Some(d) = app.dashboard.as_mut() { + d.dispatch.set_text(&reopened); + d.dispatch.set_cursor(reopened.len()); + d.dispatch.refresh_slash(&models); + } + return vec![]; + } if let Some(dashboard) = app.dashboard.as_mut() { // Records MRU and queues an off-thread persist internally. dashboard diff --git a/crates/codegen/kigi-tui/src/app/dispatch/prompt.rs b/crates/codegen/kigi-tui/src/app/dispatch/prompt.rs index 4436b2d..b6ae51a 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/prompt.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/prompt.rs @@ -331,6 +331,23 @@ pub(super) fn dispatch_send_prompt_inner( use crate::slash::command::{CommandExecCtx, CommandResult}; use crate::slash::parse_invocation; + // A required-args command submitted bare (`/model` + Enter): don't + // run into its usage error — re-open the prompt in the args phase + // (`"/model "`) so the existing suggestion dropdown lists the + // choices. This is the documented "Blocks" row of + // [`crate::slash::is_command_complete`]. + if let Some(invocation) = parse_invocation(trimmed) + && !crate::slash::is_command_complete(trimmed, agent.prompt.slash_controller.registry()) + { + let reopened = format!("/{} ", invocation.token); + agent.prompt.set_text(&reopened); + // Cursor past the trailing space — that is the args-phase signal + // the suggestion controller keys on. + agent.prompt.set_cursor(reopened.len()); + agent.prompt.refresh_slash(&agent.session.models); + return vec![]; + } + // Build execution context. let exec_result = { let mut ctx = CommandExecCtx { diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/dashboard.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/dashboard.rs index f6f4249..7c1ee4a 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/dashboard.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/dashboard.rs @@ -1270,6 +1270,38 @@ fn dashboard_slash_model_stages_pending_model() { ); } +/// `/model` + Enter (no args) on the dashboard re-opens the dispatch box in +/// the args phase — dropdown listing the catalog — instead of toasting the +/// usage error. Mirrors the session prompt path's "Blocks" contract. +#[serial_test::serial(KIGI_AGENT_DASHBOARD)] +#[test] +fn dashboard_slash_model_no_args_reopens_the_picker() { + let mut app = test_app(); + seed_model( + &mut app, + "claude-pro-max/claude-opus-4-8", + "Claude Opus 4.8", + ); + open_dashboard(&mut app); + let effects = dispatch_dashboard_dispatch_slash(&mut app, "/model".into()); + assert!(effects.is_empty()); + let d = app.dashboard.as_ref().unwrap(); + assert!( + d.error_toast.is_none(), + "no usage toast, got {:?}", + d.error_toast + ); + assert_eq!(d.dispatch.text(), "/model "); + let snap = d.dispatch.slash_snapshot(); + assert!(snap.open && !snap.cursor_in_command, "args-phase dropdown"); + assert!( + snap.matches + .iter() + .any(|row| row.display.contains("Claude Opus 4.8")), + "the picker must list the catalog models" + ); +} + /// A slash command that fails (`CommandResult::Error`) surfaces on /// the dashboard with the `✗` error prefix — command error strings /// carry no glyph of their own, and the feedback badge paints the diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/router.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/router.rs index 919644e..4005eec 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/router.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/router.rs @@ -434,14 +434,58 @@ fn slash_model_invalid_arg_produces_scrollback_error() { assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1); assert!(app.agents[&id].prompt.text().is_empty()); } +/// `/model` + Enter (required args missing) must NOT error into scrollback — +/// it re-opens the prompt in the args phase so the existing dropdown lists +/// the catalog (= the connected providers' models). This is the documented +/// "Blocks" row of `is_command_complete`, which previously had no consumer. #[test] -fn slash_model_no_args_produces_scrollback_error() { +fn slash_model_no_args_reopens_the_model_picker() { let mut app = test_app_with_agent(); let id = AgentId(0); + { + let agent = app.agents.get_mut(&id).unwrap(); + let model_id = acp::ModelId::new(std::sync::Arc::from("claude-pro-max/claude-opus-4-8")); + agent.session.models.available.insert( + model_id.clone(), + acp::ModelInfo::new(model_id, "Claude Opus 4.8".to_string()), + ); + } let initial_scrollback = app.agents[&id].scrollback.len(); let effects = dispatch(Action::SendPrompt("/model".into()), &mut app); assert!(effects.is_empty()); - assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1); + assert_eq!( + app.agents[&id].scrollback.len(), + initial_scrollback, + "no usage error may land in scrollback" + ); + assert_eq!( + app.agents[&id].prompt.text(), + "/model ", + "the prompt must re-open in the args phase" + ); + let snap = app.agents[&id].prompt.slash_snapshot(); + assert!(snap.open, "the dropdown must be open"); + assert!( + !snap.cursor_in_command, + "the dropdown must be in the ARGS phase, not command completion" + ); + assert!( + snap.matches + .iter() + .any(|row| row.display.contains("Claude Opus 4.8")), + "the picker must list the catalog models, got {:?}", + snap.matches.iter().map(|r| &r.display).collect::>() + ); +} + +/// The alias form `/m` + Enter blocks-and-reopens the same way. +#[test] +fn slash_model_alias_no_args_reopens_picker_too() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + let effects = dispatch(Action::SendPrompt("/m".into()), &mut app); + assert!(effects.is_empty()); + assert_eq!(app.agents[&id].prompt.text(), "/m "); } #[test] fn slash_hooks_opens_modal() {