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![];
|
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user