1use 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#[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#[derive(Clone)]
47pub enum CommandHandler {
48 Sync(fn(&mut AppState, &[String]) -> CommandOutcome),
52 Async(fn(&mut AppState, &[String]) -> Vec<UserAction>),
56}
57
58#[derive(Debug, Clone, PartialEq)]
60pub enum CommandOutcome {
61 Done,
63 Error(String),
65 OpenModal(Modal),
67}
68
69#[derive(Clone, Debug)]
74pub struct CommandRegistry {
75 commands: Vec<CommandSpec>,
76 skill_commands: Vec<SkillCommand>,
78}
79
80impl CommandRegistry {
81 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 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 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 CommandSpec {
180 name: "mcp",
181 aliases: &[],
182 summary: "List configured MCP servers",
183 usage: "/mcp",
184 handler: CommandHandler::Async(cmd_mcp),
185 },
186 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 pub fn with_skill_commands(mut self, skills: Vec<SkillCommand>) -> Self {
205 self.skill_commands = skills;
206 self
207 }
208
209 pub fn skill_commands(&self) -> &[SkillCommand] {
211 &self.skill_commands
212 }
213
214 pub fn commands(&self) -> &[CommandSpec] {
217 &self.commands
218 }
219
220 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 pub fn lookup_skill(&self, name: &str) -> Option<&SkillCommand> {
233 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 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 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
280fn 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 app.auto_allowed_tools.clear();
379 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
391fn cmd_goal(app: &mut AppState, args: &[String]) -> Vec<UserAction> {
398 if args.is_empty() {
399 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 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
433fn parse_goal_args(raw: &str) -> (String, u32) {
436 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 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
452fn 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 #[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 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 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 assert_eq!(names.len(), 15);
598 }
599
600 #[test]
601 fn registry_search_returns_prefix_matches_sorted() {
602 let r = CommandRegistry::default_set();
603 let hits: Vec<&str> = r.search("c").iter().map(|c| c.name).collect();
605 assert_eq!(hits, vec!["clear", "compact", "cost"]);
606 let hits: Vec<&str> = r.search("?").iter().map(|c| c.name).collect();
608 assert!(hits.contains(&"help"));
609 let hits: Vec<&str> = r.search("").iter().map(|c| c.name).collect();
611 assert_eq!(hits.len(), 15);
612 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 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}