Skip to main content

recursive/tui/
commands.rs

1//! Slash-command registry and the core built-in commands (Goal 146).
2//!
3//! A [`CommandSpec`] is a static description (name, aliases, summary,
4//! handler). The [`CommandRegistry`] wraps a `Vec<CommandSpec>` (built-ins)
5//! and a `Vec<SkillCommand>` (Goal-169 skill-backed dynamic commands), and
6//! provides exact lookup (with alias resolution) and prefix search
7//! for the completion menu.
8//!
9//! Handlers are split into [`CommandHandler::Sync`] (mutate
10//! [`AppState`] directly and return a [`CommandOutcome`]) and
11//! [`CommandHandler::Async`] (push [`UserAction`]s for the backend
12//! worker to service).
13//!
14//! Side-effects: handlers may push transcript blocks, modify
15//! `App.modals`, or set `App.should_quit`. They never block the
16//! event loop.
17
18use crate::tui::app::App as AppState;
19use crate::tui::events::UserAction;
20use crate::tui::skill_commands::SkillCommand;
21use crate::tui::ui::modal::Modal;
22
23/// One registered slash command.
24#[derive(Clone)]
25pub struct CommandSpec {
26    pub name: &'static str,
27    pub aliases: &'static [&'static str],
28    pub summary: &'static str,
29    pub usage: &'static str,
30    pub handler: CommandHandler,
31}
32
33impl std::fmt::Debug for CommandSpec {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("CommandSpec")
36            .field("name", &self.name)
37            .field("aliases", &self.aliases)
38            .field("summary", &self.summary)
39            .field("usage", &self.usage)
40            .field("handler", &"<fn>")
41            .finish()
42    }
43}
44
45/// What a command's handler does when invoked.
46#[derive(Clone)]
47pub enum CommandHandler {
48    /// Synchronous: mutate AppState in place. Returns a
49    /// [`CommandOutcome`] describing the next UI action (push modal,
50    /// surface error, …).
51    Sync(fn(&mut AppState, &[String]) -> CommandOutcome),
52    /// Asynchronous: returns a list of [`UserAction`]s to dispatch to
53    /// the backend worker. The handler may also mutate AppState
54    /// (e.g. push a System block confirming the request).
55    Async(fn(&mut AppState, &[String]) -> Vec<UserAction>),
56}
57
58/// Result of a synchronous command.
59#[derive(Debug, Clone, PartialEq)]
60pub enum CommandOutcome {
61    /// Handler completed; nothing more for the dispatcher to do.
62    Done,
63    /// Push an error block describing why the command failed.
64    Error(String),
65    /// Push a modal onto the stack.
66    OpenModal(Modal),
67}
68
69/// Vec-backed slash-command registry.
70///
71/// Stores built-in [`CommandSpec`] entries (static) plus Goal-169
72/// skill-backed commands loaded from `.recursive/skills/`.
73#[derive(Clone, Debug)]
74pub struct CommandRegistry {
75    commands: Vec<CommandSpec>,
76    /// Goal-169: skill-backed dynamic commands.
77    skill_commands: Vec<SkillCommand>,
78}
79
80impl CommandRegistry {
81    /// Build the default registry — the built-in commands.
82    pub fn default_set() -> Self {
83        Self {
84            commands: vec![
85                CommandSpec {
86                    name: "help",
87                    aliases: &["?"],
88                    summary: "Show commands & key bindings",
89                    usage: "/help",
90                    handler: CommandHandler::Sync(cmd_help),
91                },
92                CommandSpec {
93                    name: "clear",
94                    aliases: &["cls"],
95                    summary: "Clear conversation transcript",
96                    usage: "/clear",
97                    handler: CommandHandler::Sync(cmd_clear),
98                },
99                CommandSpec {
100                    name: "compact",
101                    aliases: &[],
102                    summary: "Compact the transcript",
103                    usage: "/compact",
104                    handler: CommandHandler::Async(cmd_compact),
105                },
106                CommandSpec {
107                    name: "cost",
108                    aliases: &[],
109                    summary: "Show token & cost detail",
110                    usage: "/cost",
111                    handler: CommandHandler::Sync(cmd_cost),
112                },
113                CommandSpec {
114                    name: "model",
115                    aliases: &[],
116                    summary: "Show current model",
117                    usage: "/model",
118                    handler: CommandHandler::Sync(cmd_model),
119                },
120                CommandSpec {
121                    name: "status",
122                    aliases: &[],
123                    summary: "Print runtime status",
124                    usage: "/status",
125                    handler: CommandHandler::Sync(cmd_status),
126                },
127                CommandSpec {
128                    name: "tools",
129                    aliases: &[],
130                    summary: "List available tools",
131                    usage: "/tools",
132                    handler: CommandHandler::Sync(cmd_tools),
133                },
134                CommandSpec {
135                    name: "plan",
136                    aliases: &[],
137                    summary: "Toggle planning mode (/plan on|off)",
138                    usage: "/plan on|off",
139                    handler: CommandHandler::Async(cmd_plan),
140                },
141                CommandSpec {
142                    name: "journal",
143                    aliases: &[],
144                    summary: "Show recent .dev/journal entries",
145                    usage: "/journal",
146                    handler: CommandHandler::Sync(cmd_journal),
147                },
148                CommandSpec {
149                    name: "permissions",
150                    aliases: &["perm"],
151                    summary: "Toggle runtime permission hook (/permissions on|off)",
152                    usage: "/permissions on|off",
153                    handler: CommandHandler::Sync(cmd_permissions),
154                },
155                CommandSpec {
156                    name: "exit",
157                    aliases: &["quit", "q"],
158                    summary: "Quit the TUI",
159                    usage: "/exit",
160                    handler: CommandHandler::Sync(cmd_exit),
161                },
162                // Goal-168: condition-based autonomous loop.
163                CommandSpec {
164                    name: "goal",
165                    aliases: &[],
166                    summary: "Autonomous loop until condition met",
167                    usage: "/goal <cond> [or stop after N turns] | /goal | /goal clear",
168                    handler: CommandHandler::Async(cmd_goal),
169                },
170                // Goal-171: session resume picker.
171                CommandSpec {
172                    name: "resume",
173                    aliases: &["r"],
174                    summary: "Pick a previous conversation to continue",
175                    usage: "/resume",
176                    handler: CommandHandler::Sync(cmd_resume),
177                },
178                // Goal-173: MCP server list.
179                CommandSpec {
180                    name: "mcp",
181                    aliases: &[],
182                    summary: "List configured MCP servers",
183                    usage: "/mcp",
184                    handler: CommandHandler::Async(cmd_mcp),
185                },
186                // Goal-174: theme picker.
187                CommandSpec {
188                    name: "theme",
189                    aliases: &[],
190                    summary: "Switch colour theme (dark / light / solarized)",
191                    usage: "/theme <name>",
192                    handler: CommandHandler::Sync(cmd_theme),
193                },
194            ],
195            skill_commands: Vec::new(),
196        }
197    }
198
199    /// Goal-169: register skill-backed commands alongside built-ins.
200    ///
201    /// Skill commands appear in lookup and search results.  A skill command
202    /// whose name collides with a built-in is silently shadowed by the
203    /// built-in (built-ins win).
204    pub fn with_skill_commands(mut self, skills: Vec<SkillCommand>) -> Self {
205        self.skill_commands = skills;
206        self
207    }
208
209    /// Return a reference to the loaded skill commands.
210    pub fn skill_commands(&self) -> &[SkillCommand] {
211        &self.skill_commands
212    }
213
214    /// Read-only access to the registered commands. Used by the help
215    /// modal and by tests.
216    pub fn commands(&self) -> &[CommandSpec] {
217        &self.commands
218    }
219
220    /// Look up a built-in command by canonical name *or* alias. The leading
221    /// `/` is **not** part of `name` — strip it before calling.
222    pub fn lookup(&self, name: &str) -> Option<&CommandSpec> {
223        self.commands
224            .iter()
225            .find(|c| c.name == name || c.aliases.contains(&name))
226    }
227
228    /// Look up a skill command by canonical name or alias.
229    ///
230    /// Built-ins shadow skill commands: if a built-in with the same name
231    /// exists, this returns `None` (callers should check `lookup` first).
232    pub fn lookup_skill(&self, name: &str) -> Option<&SkillCommand> {
233        // Don't expose skill if a built-in has the same name.
234        if self.lookup(name).is_some() {
235            return None;
236        }
237        self.skill_commands
238            .iter()
239            .find(|s| s.name == name || s.aliases.iter().any(|a| a == name))
240    }
241
242    /// Prefix-match across canonical names and aliases for **built-in** commands.
243    /// Returns commands whose name (or any alias) starts with `prefix`,
244    /// sorted alphabetically by canonical name. An empty prefix
245    /// returns *all* commands.
246    pub fn search(&self, prefix: &str) -> Vec<&CommandSpec> {
247        let prefix = prefix.trim_start_matches('/');
248        let mut hits: Vec<&CommandSpec> = self
249            .commands
250            .iter()
251            .filter(|c| {
252                c.name.starts_with(prefix) || c.aliases.iter().any(|a| a.starts_with(prefix))
253            })
254            .collect();
255        hits.sort_by_key(|c| c.name);
256        hits
257    }
258
259    /// Prefix-match across all skill commands.
260    pub fn search_skills(&self, prefix: &str) -> Vec<&SkillCommand> {
261        let prefix = prefix.trim_start_matches('/');
262        let mut hits: Vec<&SkillCommand> = self
263            .skill_commands
264            .iter()
265            .filter(|s| {
266                s.name.starts_with(prefix) || s.aliases.iter().any(|a| a.starts_with(prefix))
267            })
268            .collect();
269        hits.sort_by_key(|s| s.name.as_str());
270        hits
271    }
272}
273
274impl Default for CommandRegistry {
275    fn default() -> Self {
276        Self::default_set()
277    }
278}
279
280// ──────────────────────────────────────────────────────────────────────
281// Handler implementations
282// ──────────────────────────────────────────────────────────────────────
283
284fn cmd_help(_app: &mut AppState, _args: &[String]) -> CommandOutcome {
285    CommandOutcome::OpenModal(Modal::Help)
286}
287
288fn cmd_clear(app: &mut AppState, _args: &[String]) -> CommandOutcome {
289    app.reset_transcript();
290    CommandOutcome::Done
291}
292
293fn cmd_compact(app: &mut AppState, _args: &[String]) -> Vec<UserAction> {
294    app.push_system("Compacting transcript…");
295    vec![UserAction::Compact]
296}
297
298fn cmd_cost(_app: &mut AppState, _args: &[String]) -> CommandOutcome {
299    CommandOutcome::OpenModal(Modal::CostDetail)
300}
301
302fn cmd_model(_app: &mut AppState, _args: &[String]) -> CommandOutcome {
303    CommandOutcome::OpenModal(Modal::ModelInfo)
304}
305
306fn cmd_status(app: &mut AppState, _args: &[String]) -> CommandOutcome {
307    let uptime_secs = app.start_time.elapsed().as_secs();
308    let total_tokens = app.usage.total_input.saturating_add(app.usage.total_output);
309    let plan = if app.planning_mode_on { "on" } else { "off" };
310    let text = format!(
311        "Status — turn {}, blocks {}, tokens {}, uptime {}s, planning {}",
312        app.turn_count,
313        app.blocks.len(),
314        total_tokens,
315        uptime_secs,
316        plan
317    );
318    app.push_system(text);
319    CommandOutcome::Done
320}
321
322fn cmd_tools(app: &mut AppState, _args: &[String]) -> CommandOutcome {
323    CommandOutcome::OpenModal(Modal::ToolList {
324        entries: app.tool_catalog.clone(),
325    })
326}
327
328fn cmd_plan(app: &mut AppState, args: &[String]) -> Vec<UserAction> {
329    let arg = args.first().map(|s| s.to_lowercase());
330    let on = match arg.as_deref() {
331        Some("on") | Some("true") | Some("1") => true,
332        Some("off") | Some("false") | Some("0") => false,
333        _ => {
334            app.push_error("Usage: /plan on|off");
335            return Vec::new();
336        }
337    };
338    app.planning_mode_on = on;
339    app.push_system(format!("Planning mode: {}", if on { "on" } else { "off" }));
340    vec![UserAction::SetPlanningMode(on)]
341}
342
343fn cmd_journal(_app: &mut AppState, _args: &[String]) -> CommandOutcome {
344    let entries = crate::tui::ui::modal::load_recent_journal_entries(5);
345    CommandOutcome::OpenModal(Modal::Journal {
346        entries,
347        selected: 0,
348    })
349}
350
351fn cmd_exit(app: &mut AppState, _args: &[String]) -> CommandOutcome {
352    app.should_quit = true;
353    CommandOutcome::Done
354}
355
356fn cmd_permissions(app: &mut AppState, args: &[String]) -> CommandOutcome {
357    let arg = args.first().map(|s| s.to_lowercase());
358    let on = match arg.as_deref() {
359        Some("on") | Some("true") | Some("1") => true,
360        Some("off") | Some("false") | Some("0") => false,
361        _ => {
362            let current = if app
363                .permission_hook_enabled
364                .load(std::sync::atomic::Ordering::Relaxed)
365            {
366                "on"
367            } else {
368                "off"
369            };
370            app.push_error(format!("Usage: /permissions on|off  (currently {current})"));
371            return CommandOutcome::Done;
372        }
373    };
374    app.permission_hook_enabled
375        .store(on, std::sync::atomic::Ordering::Relaxed);
376    if !on {
377        // Clear auto-allow list when disabling so it starts fresh next time.
378        app.auto_allowed_tools.clear();
379        // If a modal is open, deny and close it.
380        if let Some(old) = app.pending_permission.take() {
381            let _ = old.reply.send(false);
382        }
383    }
384    app.push_system(format!(
385        "Permissions hook: {}",
386        if on { "on" } else { "off" }
387    ));
388    CommandOutcome::Done
389}
390
391/// `/goal [<condition> [or stop after N turns]] | clear`
392///
393/// - `/goal <cond>` → start a condition-based autonomous loop.
394/// - `/goal <cond> or stop after N turns` → same with explicit max turns.
395/// - `/goal` (no args) → show current goal status.
396/// - `/goal clear` → clear the active goal immediately.
397fn cmd_goal(app: &mut AppState, args: &[String]) -> Vec<UserAction> {
398    if args.is_empty() {
399        // Show current status.
400        let status = app
401            .active_goal
402            .as_ref()
403            .map(|g| {
404                format!(
405                    "Goal: \"{}\" — turn {}/{} — {}",
406                    g.condition,
407                    g.turns,
408                    g.max_turns,
409                    g.last_reason.as_deref().unwrap_or("pursuing")
410                )
411            })
412            .unwrap_or_else(|| "No active goal.".to_string());
413        app.push_system(status);
414        return Vec::new();
415    }
416
417    if args.len() == 1 && args[0].eq_ignore_ascii_case("clear") {
418        app.active_goal = None;
419        app.push_system("Goal cleared.");
420        return vec![UserAction::ClearGoal];
421    }
422
423    // Parse: "<condition> [or stop after N turns]"
424    let raw = args.join(" ");
425    let (condition, max_turns) = parse_goal_args(&raw);
426    app.push_system(format!("Goal set: \"{condition}\" (max {max_turns} turns)"));
427    vec![UserAction::SetGoal {
428        condition,
429        max_turns,
430    }]
431}
432
433/// Parse `"<condition> [or stop after N turns]"` from the raw argument string.
434/// Returns `(condition, max_turns)`. Default max_turns = 20.
435fn parse_goal_args(raw: &str) -> (String, u32) {
436    // Look for " or stop after N turns" suffix (case-insensitive).
437    let lower = raw.to_lowercase();
438    if let Some(pos) = lower.rfind(" or stop after ") {
439        let suffix = &raw[pos + " or stop after ".len()..];
440        // Try to parse the first token as a number.
441        let n: u32 = suffix
442            .split_whitespace()
443            .next()
444            .and_then(|s| s.parse().ok())
445            .unwrap_or(20);
446        let condition = raw[..pos].trim().to_string();
447        return (condition, n);
448    }
449    (raw.trim().to_string(), 20)
450}
451
452// ──────────────────────────────────────────────────────────────────────
453// Tests
454// ──────────────────────────────────────────────────────────────────────
455
456fn cmd_resume(app: &mut AppState, _args: &[String]) -> CommandOutcome {
457    use crate::tui::ui::modal::{load_recent_sessions, ResumeEntry};
458    let workspace = app.workspace_path.clone();
459    let entries: Vec<ResumeEntry> = load_recent_sessions(&workspace, 20);
460    if entries.is_empty() {
461        CommandOutcome::Error("No saved sessions found.".into())
462    } else {
463        CommandOutcome::OpenModal(Modal::ResumePicker {
464            entries,
465            selected: 0,
466        })
467    }
468}
469
470fn cmd_mcp(_app: &mut AppState, _args: &[String]) -> Vec<UserAction> {
471    vec![UserAction::ListMcpServers]
472}
473
474fn cmd_theme(app: &mut AppState, args: &[String]) -> CommandOutcome {
475    use crate::tui::ui::theme::ALL_THEMES;
476    let theme_list: Vec<&str> = ALL_THEMES.iter().map(|t| t.name).collect();
477    if args.is_empty() {
478        let names = theme_list.join(", ");
479        app.push_system(format!(
480            "Current theme: {}. Available: {}",
481            app.theme.name, names
482        ));
483        return CommandOutcome::Done;
484    }
485    let requested = args[0].to_lowercase();
486    let found = crate::tui::ui::theme::find_theme(&requested);
487    if found.name == requested {
488        app.theme = found;
489        app.push_system(format!("Theme switched to '{}'.", found.name));
490    } else {
491        let names = theme_list.join(", ");
492        app.push_error(format!(
493            "Unknown theme '{}'. Available: {}",
494            requested, names
495        ));
496    }
497    CommandOutcome::Done
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::tui::app::{App, TranscriptBlock};
504
505    fn invoke(app: &mut App, line: &str) -> InvokeResult {
506        let mut parts = line.split_whitespace();
507        let name = parts.next().unwrap_or("");
508        let args: Vec<String> = parts.map(String::from).collect();
509        let registry = app.commands.clone();
510        let Some(spec) = registry.lookup(name) else {
511            app.push_error(format!("Unknown command: /{name}. Try /help."));
512            return InvokeResult::Unknown;
513        };
514        match &spec.handler {
515            CommandHandler::Sync(f) => InvokeResult::Sync(f(app, &args)),
516            CommandHandler::Async(f) => InvokeResult::Async(f(app, &args)),
517        }
518    }
519
520    #[derive(Debug)]
521    enum InvokeResult {
522        Sync(CommandOutcome),
523        Async(Vec<UserAction>),
524        Unknown,
525    }
526
527    #[test]
528    fn cmd_mcp_is_registered() {
529        let r = CommandRegistry::default_set();
530        assert!(r.lookup("mcp").is_some(), "/mcp should be registered");
531        let spec = r.lookup("mcp").unwrap();
532        assert!(matches!(spec.handler, CommandHandler::Async(_)));
533    }
534
535    // Goal-174: theme command tests
536    #[test]
537    fn cmd_theme_switches_to_light() {
538        let mut app = App::new();
539        assert_eq!(app.theme.name, "dark");
540        invoke(&mut app, "theme light");
541        assert_eq!(app.theme.name, "light");
542    }
543
544    #[test]
545    fn cmd_theme_no_args_prints_current() {
546        let mut app = App::new();
547        let blocks_before = app.blocks.len();
548        invoke(&mut app, "theme");
549        assert!(app.blocks.len() > blocks_before);
550    }
551
552    #[test]
553    fn cmd_theme_unknown_shows_error() {
554        let mut app = App::new();
555        invoke(&mut app, "theme neon");
556        // Theme unchanged (still dark) because neon isn't known.
557        assert_eq!(app.theme.name, "dark");
558    }
559
560    #[test]
561    fn registry_finds_help_by_name_and_alias() {
562        let r = CommandRegistry::default_set();
563        assert!(r.lookup("help").is_some());
564        assert!(r.lookup("?").is_some());
565        // Fully-qualified `/help` shouldn't match — caller strips the
566        // slash before lookup.
567        assert!(r.lookup("/help").is_none());
568        assert!(r.lookup("nope").is_none());
569    }
570
571    #[test]
572    fn registry_includes_all_fifteen_commands() {
573        let r = CommandRegistry::default_set();
574        let names: Vec<&str> = r.commands().iter().map(|c| c.name).collect();
575        for expected in &[
576            "help",
577            "clear",
578            "compact",
579            "cost",
580            "model",
581            "status",
582            "tools",
583            "plan",
584            "journal",
585            "exit",
586            "permissions",
587            "goal",
588            "mcp",
589            "theme",
590        ] {
591            assert!(
592                names.contains(expected),
593                "missing /{expected}: have {names:?}"
594            );
595        }
596        // 11 built-in commands plus /goal, /resume, /mcp, and /theme = 15.
597        assert_eq!(names.len(), 15);
598    }
599
600    #[test]
601    fn registry_search_returns_prefix_matches_sorted() {
602        let r = CommandRegistry::default_set();
603        // "c" prefix matches clear, compact, cost.
604        let hits: Vec<&str> = r.search("c").iter().map(|c| c.name).collect();
605        assert_eq!(hits, vec!["clear", "compact", "cost"]);
606        // alias-prefix hit: "?" matches /help via alias.
607        let hits: Vec<&str> = r.search("?").iter().map(|c| c.name).collect();
608        assert!(hits.contains(&"help"));
609        // Empty prefix returns everything (sorted).
610        let hits: Vec<&str> = r.search("").iter().map(|c| c.name).collect();
611        assert_eq!(hits.len(), 15);
612        // Sorted check.
613        let mut sorted = hits.clone();
614        sorted.sort();
615        assert_eq!(hits, sorted);
616    }
617
618    #[test]
619    fn clear_resets_transcript_and_usage() {
620        let mut app = App::new();
621        app.usage.total_input = 1000;
622        app.usage.total_output = 500;
623        app.blocks.push(TranscriptBlock::User {
624            text: "hello".into(),
625        });
626        app.blocks.push(TranscriptBlock::Assistant {
627            text: "hi".into(),
628            streaming: false,
629            latency_ms: None,
630        });
631        let _ = invoke(&mut app, "clear");
632        // Reset clears all old blocks and pushes the cleared message.
633        assert_eq!(app.blocks.len(), 1);
634        assert!(matches!(
635            &app.blocks[0],
636            TranscriptBlock::System { text } if text.contains("cleared")
637        ));
638        assert_eq!(app.usage.total_input, 0);
639        assert_eq!(app.usage.total_output, 0);
640    }
641
642    #[test]
643    fn exit_sets_should_quit() {
644        let mut app = App::new();
645        let _ = invoke(&mut app, "exit");
646        assert!(app.should_quit);
647        let mut app2 = App::new();
648        let _ = invoke(&mut app2, "q");
649        assert!(app2.should_quit);
650        let mut app3 = App::new();
651        let _ = invoke(&mut app3, "quit");
652        assert!(app3.should_quit);
653    }
654
655    #[test]
656    fn status_appends_system_block_with_turn_count() {
657        let mut app = App::new();
658        app.turn_count = 7;
659        let _ = invoke(&mut app, "status");
660        let last = app.blocks.last().unwrap();
661        match last {
662            TranscriptBlock::System { text } => {
663                assert!(text.contains("turn 7"), "got {text:?}");
664                assert!(text.contains("planning off"));
665            }
666            other => panic!("expected System, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn unknown_command_pushes_error_block() {
672        let mut app = App::new();
673        let r = invoke(&mut app, "frobnicate");
674        assert!(matches!(r, InvokeResult::Unknown));
675        match app.blocks.last() {
676            Some(TranscriptBlock::Error { text }) => {
677                assert!(text.contains("Unknown command"), "got {text:?}");
678            }
679            other => panic!("expected Error block, got {other:?}"),
680        }
681    }
682
683    #[test]
684    fn plan_on_off_toggles_state_and_pushes_system_block() {
685        let mut app = App::new();
686        let r = invoke(&mut app, "plan on");
687        match r {
688            InvokeResult::Async(actions) => {
689                assert_eq!(actions, vec![UserAction::SetPlanningMode(true)]);
690            }
691            other => panic!("expected async actions, got {other:?}"),
692        }
693        assert!(app.planning_mode_on);
694
695        let r = invoke(&mut app, "plan off");
696        match r {
697            InvokeResult::Async(actions) => {
698                assert_eq!(actions, vec![UserAction::SetPlanningMode(false)]);
699            }
700            other => panic!("expected async actions, got {other:?}"),
701        }
702        assert!(!app.planning_mode_on);
703    }
704
705    #[test]
706    fn plan_without_arg_pushes_error_and_no_action() {
707        let mut app = App::new();
708        let r = invoke(&mut app, "plan");
709        match r {
710            InvokeResult::Async(actions) => {
711                assert!(actions.is_empty(), "expected no actions, got {actions:?}");
712            }
713            other => panic!("expected async result, got {other:?}"),
714        }
715        match app.blocks.last() {
716            Some(TranscriptBlock::Error { text }) => assert!(text.contains("Usage")),
717            other => panic!("expected Error, got {other:?}"),
718        }
719    }
720
721    #[test]
722    fn help_opens_help_modal() {
723        let mut app = App::new();
724        let r = invoke(&mut app, "help");
725        match r {
726            InvokeResult::Sync(CommandOutcome::OpenModal(Modal::Help)) => {}
727            other => panic!("expected OpenModal(Help), got {other:?}"),
728        }
729    }
730
731    #[test]
732    fn cost_opens_cost_modal() {
733        let mut app = App::new();
734        let r = invoke(&mut app, "cost");
735        assert!(matches!(
736            r,
737            InvokeResult::Sync(CommandOutcome::OpenModal(Modal::CostDetail))
738        ));
739    }
740
741    #[test]
742    fn tools_opens_modal_with_catalog() {
743        let mut app = App::new();
744        let r = invoke(&mut app, "tools");
745        match r {
746            InvokeResult::Sync(CommandOutcome::OpenModal(Modal::ToolList { entries })) => {
747                assert!(entries.iter().any(|(n, _)| n == "read_file"));
748            }
749            other => panic!("expected OpenModal(ToolList), got {other:?}"),
750        }
751    }
752
753    #[test]
754    fn compact_returns_compact_action() {
755        let mut app = App::new();
756        let r = invoke(&mut app, "compact");
757        match r {
758            InvokeResult::Async(actions) => {
759                assert_eq!(actions, vec![UserAction::Compact]);
760            }
761            other => panic!("expected Async([Compact]), got {other:?}"),
762        }
763    }
764}