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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<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]
|
||||
fn slash_hooks_opens_modal() {
|
||||
|
||||
Reference in New Issue
Block a user