feat(tui): /model + Enter opens the model picker instead of erroring

A required-args slash command submitted bare (/model, or /m) ran straight
into its usage error. The completeness contract already existed —
is_command_complete's documented 'Blocks' row — but had zero non-test
callers; both dispatch paths ran the command unconditionally.

Both the session prompt path and the dashboard dispatch box now consult
is_command_complete before running: an incomplete required-args command
re-opens the input as '/{command} ' with the cursor in the args phase, so
the existing suggestion dropdown lists the choices (for /model: the
catalog — by construction the connected providers' models, after
1bb10ef). Generalizes to every future required-args command; no new UI.

Verified: kigi-tui 6872 tests green, clippy clean.
This commit is contained in:
2026-07-22 17:28:58 -04:00
parent 1bb10ef7f1
commit 66d5194ee3
4 changed files with 108 additions and 2 deletions
@@ -1251,6 +1251,19 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
} }
return vec![]; 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() { if let Some(dashboard) = app.dashboard.as_mut() {
// Records MRU and queues an off-thread persist internally. // Records MRU and queues an off-thread persist internally.
dashboard dashboard
@@ -331,6 +331,23 @@ pub(super) fn dispatch_send_prompt_inner(
use crate::slash::command::{CommandExecCtx, CommandResult}; use crate::slash::command::{CommandExecCtx, CommandResult};
use crate::slash::parse_invocation; 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. // Build execution context.
let exec_result = { let exec_result = {
let mut ctx = CommandExecCtx { let mut ctx = CommandExecCtx {
@@ -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 /// A slash command that fails (`CommandResult::Error`) surfaces on
/// the dashboard with the `✗` error prefix — command error strings /// the dashboard with the `✗` error prefix — command error strings
/// carry no glyph of their own, and the feedback badge paints the /// carry no glyph of their own, and the feedback badge paints the
@@ -434,14 +434,58 @@ fn slash_model_invalid_arg_produces_scrollback_error() {
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1); assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
assert!(app.agents[&id].prompt.text().is_empty()); 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] #[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 mut app = test_app_with_agent();
let id = AgentId(0); 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 initial_scrollback = app.agents[&id].scrollback.len();
let effects = dispatch(Action::SendPrompt("/model".into()), &mut app); let effects = dispatch(Action::SendPrompt("/model".into()), &mut app);
assert!(effects.is_empty()); 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::<Vec<_>>()
);
}
/// 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] #[test]
fn slash_hooks_opens_modal() { fn slash_hooks_opens_modal() {