diff --git a/docs/slash-commands.md b/docs/slash-commands.md new file mode 100644 index 00000000..cffddb02 --- /dev/null +++ b/docs/slash-commands.md @@ -0,0 +1,190 @@ +# Slash commands + +Generated from the command catalog. Run `cargo run --package claurst-commands --example command_reference` from `src-rust/` to regenerate. + +Type `/` for contextual suggestions, or open the command palette with Ctrl+K. Enter opens a command group; choose its Run entry to execute the parent. Use `/help ` for a command, including grouped paths. Suggestions use local session state and recent commands. + +## Start & Navigate + +| Command | Usage | Description | +|---|---|---| +| `/clear` | `/clear` | Clear the conversation history | +| `/exit` | `/exit` | Exit Coven | +| `/help` | `/help [command]` | Show available commands and usage information | +| `/init` | `/init` | Initialize a new project with AGENTS.md | +| `/search` | `/search ` | Search across all sessions | +| `/session` | `/session ` | Show or manage conversation sessions | +| `/session rename` | `/session rename ` | Rename the current session | +| `/session resume` | `/session resume [id]` | Resume a saved session | +| `/session fork` | `/session fork [message_index]` | Fork the current session | +| `/session branch` | `/session branch [arguments]` | Create a session branch | +| `/session tag` | `/session tag [arguments]` | Tag the current session | +| `/session add-dir` | `/session add-dir ` | Add an allowed workspace directory | + +## Work & Review + +| Command | Usage | Description | +|---|---|---| +| `/commit` | `/commit [message]` | Ask Coven to commit staged changes | +| `/diff` | `/diff` | Show git diff of changes in the working directory | +| `/memory` | `/memory [edit\|clear]` | View, edit, or clear AGENTS.md memory files | +| `/review` | `/review [pr-comments]` | Review code changes via LLM and optionally post to GitHub PR | +| `/review pr-comments` | `/review pr-comments` | Read pull request comments | +| `/review security` | `/review security [path]` | Run a security-focused review | +| `/review ultra` | `/review ultra [path]` | Run an exhaustive review | +| `/rewind` | `/rewind [list\|diff\|last]` | Rewind the conversation or roll back a turn's file changes | +| `/rewind list` | `/rewind list` | List recorded file-change checkpoints | +| `/rewind diff` | `/rewind diff [checkpoint]` | Preview a recorded file-change diff | + +## Conversation + +| Command | Usage | Description | +|---|---|---| +| `/find` | `/find` | Search the current transcript | +| `/compact` | `/compact` | Compact the conversation to reduce token usage | +| `/export` | `/export [copy [n]\|share] [--format markdown\|json] [--output ]` | Export conversation to markdown or JSON | +| `/export share` | `/export share` | Create a shareable session export | +| `/export copy` | `/export copy` | Copy the session export to the clipboard | +| `/plan` | `/plan [prompt]` | Enter plan mode – model outputs a plan for approval before acting | +| `/tasks` | `/tasks` | List and manage background tasks | +| `/thinking` | `/thinking ` | Toggle extended thinking mode | +| `/thinking back` | `/thinking back` | View earlier thinking traces | + +## Account & Models + +| Command | Usage | Description | +|---|---|---| +| `/account` | `/account ` | Manage accounts and provider connections | +| `/account login` | `/account login [--console] [--codex] [--label ]` | Sign in to a provider | +| `/account logout` | `/account logout` | Sign out of a provider | +| `/account list` | `/account list` | List configured accounts | +| `/account dedupe` | `/account dedupe` | Remove duplicate credentials | +| `/account providers` | `/account providers` | List available providers | +| `/account connect` | `/account connect` | Connect a provider account | +| `/account switch` | `/account switch [name]` | Switch the active account | +| `/account refresh` | `/account refresh` | Refresh provider authentication | +| `/model` | `/model [name]` | Show or change the current model | +| `/permissions` | `/permissions [sandbox]` | View or change tool permission settings | +| `/permissions sandbox` | `/permissions sandbox` | Configure sandbox permissions | +| `/usage` | `/usage [cost\|context\|stats]` | Show API usage, quotas, and rate limit status | +| `/usage cost` | `/usage cost` | Show session cost details | +| `/usage context` | `/usage context` | Show context window usage | +| `/usage stats` | `/usage stats` | Show detailed session statistics | + +## Customize + +| Command | Usage | Description | +|---|---|---| +| `/vim` | `/vim` | Toggle Vim input mode | +| `/voice` | `/voice` | Toggle voice input | +| `/config` | `/config [show\|get\|set\|unset\|] ...` | Show or modify configuration settings | +| `/config splash` | `/config splash [show\|hide\|toggle]` | Configure the empty-session splash screen | +| `/config theme` | `/config theme [name]` | Select the display theme | +| `/config output-style` | `/config output-style [style]` | Select the response output style | +| `/config keybindings` | `/config keybindings` | Open keybinding configuration | +| `/config import` | `/config import` | Import configuration from another tool | +| `/config advisor` | `/config advisor [model\|off]` | Configure the advisor model | +| `/effort` | `/effort [low\|normal\|high\|fast [on\|off]]` | Set the model's thinking effort (low \| normal \| high) | +| `/effort fast` | `/effort fast [on\|off]` | Enable fast response mode | + +## Extend + +| Command | Usage | Description | +|---|---|---| +| `/help feedback` | `/help feedback [report]` | Submit feedback about Coven | +| `/integrations` | `/integrations ` | Configure external integrations | +| `/integrations hooks` | `/integrations hooks` | Configure lifecycle hooks | +| `/integrations mcp` | `/integrations mcp [arguments]` | Manage MCP servers | +| `/integrations chrome` | `/integrations chrome` | Connect Chrome integration | +| `/learn` | `/learn` | Codify a script or workflow we just built into a reusable skill (Hermes) | +| `/plugin` | `/plugin [reload]` | Manage plugins | +| `/plugin reload` | `/plugin reload` | Reload installed plugins | +| `/skills` | `/skills` | List available skills in .coven-code/commands/ | + +## Coven & Advanced + +| Command | Usage | Description | +|---|---|---| +| `/coven` | `/coven [status\|sessions\|familiars\|log\|run\|daemon\|goal\|help] ...` | Drive the local Coven daemon (sessions, harness runs, rituals) | +| `/coven goal` | `/coven goal [objective\|status\|pause\|resume\|clear\|complete]` | Manage durable session goals | +| `/familiar` | `/familiar ` | Your familiar & agents — switch persona, inspect, and manage | +| `/familiar incant` | `/familiar incant [arguments]` | Run a familiar incantation | +| `/familiar whisper` | `/familiar whisper ` | Ask a familiar side question | +| `/familiar handoff` | `/familiar handoff ` | Hand work to a familiar | +| `/familiar list` | `/familiar list` | List available familiars | +| `/familiar managed` | `/familiar managed [arguments]` | Manage familiar execution | +| `/stash` | `/stash ` | Store useful links and attachments | +| `/stash link` | `/stash link [arguments]` | Store and manage links | +| `/stash attach` | `/stash attach [arguments]` | Store and manage attachments | +| `/status` | `/status [doctor]` | Show comprehensive system and session status | +| `/status doctor` | `/status doctor` | Run diagnostic checks | +| `/update` | `/update ` | Check for updates and download the latest release | +| `/update version` | `/update version` | Show the installed Coven version | +| `/update notes` | `/update notes` | Show recent Coven release notes | + +## Removed paths + +These paths no longer execute. Their replacement remains available. + +| Removed path | Use instead | +|---|---| +| `/cost` | `/usage cost` | +| `/context` | `/usage context` | +| `/stats` | `/usage stats` | +| `/rename` | `/session rename` | +| `/changes` | `/diff` | +| `/version` | `/update version` | +| `/v` | `/update version` | +| `/release-notes` | `/update notes` | +| `/whats-new` | `/update notes` | +| `/changelog` | `/update notes` | +| `/resume` | `/session resume` | +| `/r` | `/session resume` | +| `/continue` | `/session resume` | +| `/reload-plugins` | `/plugin reload` | +| `/doctor` | `/status doctor` | +| `/login` | `/account login` | +| `/logout` | `/account logout` | +| `/accounts` | `/account list` | +| `/accounts dedupe` | `/account dedupe` | +| `/switch` | `/account switch` | +| `/refresh` | `/account refresh` | +| `/incant` | `/familiar incant` | +| `/handoff` | `/familiar handoff` | +| `/hooks` | `/integrations hooks` | +| `/import-config` | `/config import` | +| `/mcp` | `/integrations mcp` | +| `/fork` | `/session fork` | +| `/theme` | `/config theme` | +| `/output-style` | `/config output-style` | +| `/keybindings` | `/config keybindings` | +| `/splash` | `/config splash` | +| `/welcome-screen` | `/config splash` | +| `/share` | `/export share` | +| `/add-dir` | `/session add-dir` | +| `/agents` | `/familiar list` | +| `/branch` | `/session branch` | +| `/tag` | `/session tag` | +| `/pr-comments` | `/review pr-comments` | +| `/copy` | `/export copy` | +| `/chrome` | `/integrations chrome` | +| `/fast` | `/effort fast` | +| `/speed` | `/effort fast` | +| `/think-back` | `/thinking back` | +| `/thinkback` | `/thinking back` | +| `/whisper` | `/familiar whisper` | +| `/btw` | `/familiar whisper` | +| `/sandbox` | `/permissions sandbox` | +| `/sandbox-toggle` | `/permissions sandbox` | +| `/advisor` | `/config advisor` | +| `/providers` | `/account providers` | +| `/connect` | `/account connect` | +| `/managed-agents` | `/familiar managed` | +| `/goal` | `/coven goal` | +| `/link` | `/stash link` | +| `/links` | `/stash link` | +| `/attach` | `/stash attach` | +| `/attachments` | `/stash attach` | +| `/feedback` | `/help feedback` | +| `/bug` | `/help feedback` | +| `/survey` | `/help feedback` | diff --git a/scripts/tui-tests/README.md b/scripts/tui-tests/README.md index 1e77df2b..4c5dd601 100644 --- a/scripts/tui-tests/README.md +++ b/scripts/tui-tests/README.md @@ -50,8 +50,8 @@ screen, and runs `run.sh`. No secrets or network access required. |---|---|---| | `cases/01_headless.sh` | CLI surface (no TTY) | `--version` semver, `--help` usage + flags, unknown flag exits non-zero, `auth status`, `models` catalog, `--dump-system-prompt` | | `cases/02_startup.sh` | TUI cold start | title banner, welcome/changelog sections, input glyph, footer keybindings, no panic on boot | -| `cases/03_command_palette.sh` | Slash palette | `/` opens it, lists `/clear` `/compact` `/config`, typing filters the list, `Ctrl+K` entry point | -| `cases/04_help_overlay.sh` | Help overlay | `?` opens keybinding + command reference, `Esc` closes it | +| `cases/03_command_palette.sh` | Slash palette | `/` shows contextual suggestions, typing filters the list, `Ctrl+K` opens grouped navigation, one Enter runs a leaf, retired paths are rejected | +| `cases/04_help_overlay.sh` | Help overlay | `?` opens keybinding + grouped command reference, search finds `/account login`, `Esc` closes it | | `cases/05_input_editing.sh` | Prompt input | typed text echoes into the buffer, `Ctrl+U` clears it | | `cases/06_quit.sh` | Shutdown | `Ctrl+C` twice exits cleanly back to the shell | | `cases/07_paste_burst.sh` | Paste burst | a line delivered in one write still submits; interior newlines coalesce without submitting | diff --git a/scripts/tui-tests/cases/03_command_palette.sh b/scripts/tui-tests/cases/03_command_palette.sh index fe429929..9c53fc3a 100644 --- a/scripts/tui-tests/cases/03_command_palette.sh +++ b/scripts/tui-tests/cases/03_command_palette.sh @@ -10,25 +10,23 @@ tc_palette() { if ! have_tmux; then _skip "tmux not installed"; return 0; fi tui_start || { tui_stop; return 0; } - # Typing "/" as the first character opens the palette. + # Contextual roots replace the expired standalone account/integration paths. tui_type "/" - if ! wait_for "/clear"; then - _fail "palette opened on '/' (/clear never appeared)" "$(tui_capture)" + if ! wait_for "/help"; then + _fail "palette opened on '/' (/help never appeared)" "$(tui_capture)" tui_stop; return 0 fi local s; s="$(tui_capture)" - # Assert on entries near the top of the alphabetical list — the palette - # viewport shows a handful of rows, so entries further down (e.g. /config) - # scroll out of view as new commands are registered. - assert_contains "$s" "/accounts" "palette lists /accounts" - assert_contains "$s" "/attach" "palette lists /attach" - assert_contains "$s" "/chrome" "palette lists /chrome" - assert_contains "$s" "/clear" "palette lists /clear" + assert_contains "$s" "/help" "palette lists contextual /help" + assert_absent "$s" "/accounts " "palette omits retired /accounts" + assert_absent "$s" "/attach " "palette omits retired /attach" + assert_absent "$s" "/chrome " "palette omits retired /chrome" # Filtering narrows the list: "/config" should surface /config, drop /clear. tui_type "config" - # Wait for the filter to take effect (a non-matching entry disappears). - wait_absent "/clear" 5 + # The contextual starting list may already omit /clear. Wait for the + # requested result instead, proving the new filtered frame has rendered. + wait_for "/config" 5 s="$(tui_capture)" assert_contains "$s" "/config" "filter '/config' keeps /config" assert_absent "$s" "/clear" "filter '/config' drops /clear" @@ -39,12 +37,38 @@ tc_palette() { # Ctrl+K is the second documented entry point to the palette. tui_keys C-k - if wait_for "/clear" 5; then + if wait_for "Command Palette" 5; then _pass "Ctrl+K opens the command palette" else _fail "Ctrl+K opens the command palette" "$(tui_capture)" fi tui_keys Escape; tui_settle + # Enter on a selected palette leaf submits through the CLI in one step. + tui_keys C-k; tui_settle + tui_type "help feedback"; tui_settle + tui_keys Enter + if wait_for "Session Feedback" 5; then + _pass "palette leaf executes on one Enter" + else + _fail "palette leaf executes on one Enter" "$(tui_capture)" + fi + + tui_keys Escape; tui_settle + + # Exact retired input must be rejected before automatic suggestion acceptance. + tui_type "/fork"; tui_settle; tui_keys Enter + if wait_for "/fork was removed; use /session fork" 5; then + _pass "retired /fork cannot execute through completion" + else + _fail "retired /fork cannot execute through completion" "$(tui_capture)" + fi + tui_type "/theme"; tui_settle; tui_keys Enter + if wait_for "/theme was removed; use /config theme" 5; then + _pass "retired /theme cannot open picker through completion" + else + _fail "retired /theme cannot open picker through completion" "$(tui_capture)" + fi + tui_stop } diff --git a/scripts/tui-tests/cases/04_help_overlay.sh b/scripts/tui-tests/cases/04_help_overlay.sh index b266cbe0..fcbae520 100644 --- a/scripts/tui-tests/cases/04_help_overlay.sh +++ b/scripts/tui-tests/cases/04_help_overlay.sh @@ -14,8 +14,8 @@ tc_help() { # The overlay is tall; under load the lower rows render a beat after the # top. Poll for a late item before snapshotting so the assertions don't # race the draw. - if ! wait_for "/permissions"; then - _fail "help overlay rendered (/permissions never appeared)" "$(tui_capture)" + if ! wait_for "/session resume"; then + _fail "help overlay rendered (/session resume never appeared)" "$(tui_capture)" tui_stop; return 0 fi local s; s="$(tui_capture)" @@ -23,8 +23,13 @@ tc_help() { assert_contains "$s" "Command palette" "help overlay documents the command palette" assert_contains "$s" "Model picker" "help overlay documents the model picker" # Command reference section. - assert_contains "$s" "/login" "help overlay lists /login" - assert_contains "$s" "/permissions" "help overlay lists /permissions" + assert_contains "$s" "/session resume" "help overlay lists grouped session commands" + tui_type "account login" + if wait_for "/account login" 5; then + _pass "help filter finds canonical /account login" + else + _fail "help filter finds canonical /account login" "$(tui_capture)" + fi # Esc closes the overlay. tui_keys Escape diff --git a/scripts/tui-tests/cases/07_paste_burst.sh b/scripts/tui-tests/cases/07_paste_burst.sh index a6efa3ad..e097d81c 100755 --- a/scripts/tui-tests/cases/07_paste_burst.sh +++ b/scripts/tui-tests/cases/07_paste_burst.sh @@ -29,7 +29,7 @@ tc_paste_burst() { tui_paste "/help"$'\r' # The overlay is tall; poll for a late-rendered item so the assertion does # not race the draw (same guard as the help-overlay case). - if wait_for "/permissions"; then + if wait_for "/session resume"; then _pass "burst-delivered line submits (help overlay opened)" local s; s="$(tui_capture)" assert_contains "$s" "Toggle help" "burst submit reached the slash-command path" diff --git a/src-rust/crates/cli/Cargo.toml b/src-rust/crates/cli/Cargo.toml index f8075a6f..8cc2163b 100644 --- a/src-rust/crates/cli/Cargo.toml +++ b/src-rust/crates/cli/Cargo.toml @@ -12,7 +12,7 @@ default = ["voice"] voice = ["claurst-core/voice", "claurst-tui/voice"] # Opt-in `/steer` command (see crates/tui). Build with `--features steer`. -steer = ["claurst-tui/steer"] +steer = ["claurst-commands/steer", "claurst-tui/steer"] [dependencies] claurst-core = { workspace = true } diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index db6de20f..1e173857 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2075,6 +2075,17 @@ fn decide_goal_turn_accounting( } } +fn refresh_command_discovery(app: &mut claurst_tui::App, config: &Config, cwd: &std::path::Path) { + let plugin_commands = claurst_plugins::global_plugin_registry() + .map(|registry| registry.all_command_defs()) + .unwrap_or_default(); + app.install_command_catalog(claurst_commands::composed_catalog( + config, + cwd, + &plugin_commands, + )); +} + // Allowed: this is the main interactive-mode bootstrap. Its inputs are all // distinct, separately-constructed services (config, settings, HTTP client, // tool registry, tool execution context, query loop knobs, etc.) and @@ -2182,6 +2193,8 @@ async fn run_interactive( // Set up terminal let mut terminal = setup_terminal()?; let mut app = App::new(live_config.clone(), cost_tracker.clone()); + app.config.project_dir = Some(tool_ctx.working_dir.clone()); + refresh_command_discovery(&mut app, &live_config, &tool_ctx.working_dir); if let Some(warning) = resume_warning { app.status_message = Some(warning); } @@ -2298,7 +2311,7 @@ async fn run_interactive( app.onboarding_dialog.show_provider_setup(); } else { app.status_message = - Some("No provider configured. Run /connect to set one up.".to_string()); + Some("No provider configured. Run /account connect to set one up.".to_string()); } } else if !settings.has_completed_onboarding { // User has credentials but hasn't formally completed onboarding — mark it done @@ -2629,6 +2642,17 @@ async fn run_interactive( app.refresh_prompt_input(); continue; } + // Retired input must reach the lifecycle gate before completion + // can replace it with a suggested canonical command. + if app.prompt_input.text.starts_with('/') { + let (name, args) = claurst_tui::input::parse_slash_command(&app.prompt_input.text); + if let Err(error) = claurst_commands::preflight_interactive_slash_command(name, args, &mut cmd_ctx) { + app.take_input(); + app.status_message = Some(error); + continue; + } + } + // If a slash-command suggestion is active, accept and execute immediately. if !app.prompt_input.suggestions.is_empty() && app.prompt_input.suggestion_index.is_some() @@ -2647,20 +2671,27 @@ async fn run_interactive( if input.starts_with('/') { let (cmd_name, cmd_args) = claurst_tui::input::parse_slash_command(&input); - let cmd_name = cmd_name.to_string(); + let cmd_name = cmd_name.to_ascii_lowercase(); let cmd_args = cmd_args.to_string(); + if let Err(error) = claurst_commands::preflight_interactive_slash_command(&cmd_name, &cmd_args, &mut cmd_ctx) { + app.status_message = Some(error); + continue; + } + let canonical_path = claurst_commands::canonical_catalog_path(&cmd_name, &cmd_args) + .or_else(|| app.canonical_command_path(&cmd_name, &cmd_args)); + // ── /steer (opt-in feature) ───────────────────── // Explicit steering: queue the message when a turn is in // flight (it auto-runs after the current turn, mirroring // Enter-while-busy), or submit it immediately when idle. #[cfg(feature = "steer")] - if cmd_name == "steer" { - let msg = cmd_args.trim().to_string(); + if canonical_path.as_deref() == Some("thinking steer") { + let msg = cmd_args.split_once(char::is_whitespace).map(|(_, message)| message.trim()).unwrap_or_default().to_string(); if msg.is_empty() { app.notifications.push( claurst_tui::NotificationKind::Info, - "Usage: /steer — steer the running turn" + "Usage: /thinking steer — steer the running turn" .to_string(), Some(4), ); @@ -2700,7 +2731,6 @@ async fn run_interactive( "model" | "theme" | "resume" - | "session" | "vim" | "vi" | "voice" @@ -2743,7 +2773,16 @@ async fn run_interactive( // Always runs — some commands need BOTH (e.g. /clear clears // app state via TUI AND the messages vec via CLI). cmd_ctx.messages = messages.clone(); - let cli_result = execute_command(&input, &mut cmd_ctx).await; + let picker_only = canonical_path.as_deref() == Some("session resume") + && cmd_args.split_whitespace().count() == 1; + let cli_result = if handled_by_tui && (canonical_path.is_none() || picker_only) { + None + } else { + execute_command(&input, &mut cmd_ctx).await + }; + let command_succeeded = !matches!(&cli_result, Some(CommandResult::Error(_))) && (handled_by_tui || cli_result.is_some()); + let refresh_catalog = matches!(&cli_result, Some(CommandResult::ResumeSession(_) | CommandResult::ConfigChange(_) | CommandResult::ConfigChangeMessage(_, _))) + || command_succeeded && canonical_path.as_deref() == Some("plugin reload"); // Start optimistically true; set false for Silent/None below. let mut handled_by_cli = cli_result.is_some(); @@ -2867,7 +2906,7 @@ async fn run_interactive( Some(refreshed.provider_registry), refreshed.auth_store, false, - "Saved provider state cleared. Run /connect to reconnect." + "Saved provider state cleared. Run /account connect to reconnect." .to_string(), ); } @@ -3066,7 +3105,7 @@ async fn run_interactive( // Sync effort visual + API level when CLI handled // /effort with explicit args (/effort high). - if handled_by_cli && cmd_name == "effort" && !cmd_args.is_empty() { + if handled_by_cli && canonical_path.as_deref() == Some("effort") && !cmd_args.is_empty() { if let Some(level) = claurst_core::effort::EffortLevel::parse(&cmd_args) { @@ -3104,8 +3143,21 @@ async fn run_interactive( } if !handled_by_cli && !handled_by_tui { - app.status_message = - Some(format!("Unknown command: /{}", cmd_name)); + let suggestions = app.unknown_command_suggestions(&cmd_name); + let mut message = format!("Unknown command: /{cmd_name}"); + if !suggestions.is_empty() { + message.push_str(&format!(". Try {}", suggestions.join(", "))); + } + app.status_message = Some(message); + } + + if refresh_catalog { + refresh_command_discovery(&mut app, &cmd_ctx.config, &cmd_ctx.working_dir); + } + if command_succeeded { + if let Some(path) = canonical_path { + app.record_recent_command(path); + } } // If a UserMessage was queued (e.g. /compact), submit it. @@ -3498,7 +3550,9 @@ async fn run_interactive( continue; } - app.handle_key_event(key); + if app.handle_key_event(key) { + app.pending_auto_submit = true; + } cmd_ctx.config = app.config.clone(); tool_ctx.config = app.config.clone(); if let Some(manager) = tool_ctx.permission_manager.as_ref() { diff --git a/src-rust/crates/commands/Cargo.toml b/src-rust/crates/commands/Cargo.toml index 0c9348da..b11f78fa 100644 --- a/src-rust/crates/commands/Cargo.toml +++ b/src-rust/crates/commands/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [features] # Pass-through features from cc-core ultraplan = ["claurst-core/ultraplan"] +steer = ["claurst-tui/steer"] [dependencies] claurst-core = { workspace = true } diff --git a/src-rust/crates/commands/examples/command_reference.rs b/src-rust/crates/commands/examples/command_reference.rs new file mode 100644 index 00000000..6f1b9744 --- /dev/null +++ b/src-rust/crates/commands/examples/command_reference.rs @@ -0,0 +1,14 @@ +fn main() -> Result<(), Box> { + let reference = claurst_commands::catalog::render_reference(); + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../docs/slash-commands.md"); + if std::env::args().any(|argument| argument == "--check") { + if std::fs::read_to_string(&path)? != reference { + return Err("slash command reference is stale; regenerate it with the command_reference example".into()); + } + } else { + std::fs::write(&path, reference)?; + println!("Updated {}", path.display()); + } + Ok(()) +} diff --git a/src-rust/crates/commands/src/catalog.rs b/src-rust/crates/commands/src/catalog.rs new file mode 100644 index 00000000..262e08e7 --- /dev/null +++ b/src-rust/crates/commands/src/catalog.rs @@ -0,0 +1,1563 @@ +use claurst_core::CommandContextSignal; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Stable groupings for built-in commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandCategory { + StartNavigate, + WorkReview, + Conversation, + AccountModels, + Customize, + Extend, + CovenAdvanced, + Other, +} + +impl CommandCategory { + /// Human-readable heading used by command discovery surfaces. + pub const fn label(self) -> &'static str { + match self { + Self::StartNavigate => "Start & Navigate", + Self::WorkReview => "Work & Review", + Self::Conversation => "Conversation", + Self::AccountModels => "Account & Models", + Self::Customize => "Customize", + Self::Extend => "Extend", + Self::CovenAdvanced => "Coven & Advanced", + Self::Other => "Other", + } + } + + /// The stable order of user-facing command groups. + pub const fn ordered() -> &'static [Self] { + &[ + Self::StartNavigate, + Self::WorkReview, + Self::Conversation, + Self::AccountModels, + Self::Customize, + Self::Extend, + Self::CovenAdvanced, + ] + } + + /// Position in the canonical command-discovery order. + /// + /// `Other` is deliberately last because it is not a user-facing section. + pub fn rank(self) -> usize { + Self::ordered() + .iter() + .position(|category| *category == self) + .unwrap_or(Self::ordered().len()) + } +} + +/// Where a command was defined. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandSource { + BuiltIn, + User, + Skill, + Plugin, +} + +/// Availability and migration state of a command path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum CommandLifecycle { + Active, + Advanced, + Removed { replacement: String }, +} + +impl CommandLifecycle { + /// The path users should move to, if this lifecycle supersedes a command. + pub fn replacement(&self) -> Option<&str> { + match self { + Self::Removed { replacement } => Some(replacement), + Self::Active | Self::Advanced => None, + } + } +} + +/// Owned metadata for a canonical command path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandSpec { + pub path: String, + pub aliases: Vec, + pub summary: String, + pub usage: String, + pub category: CommandCategory, + pub keywords: Vec, + pub lifecycle: CommandLifecycle, + pub source: CommandSource, + pub suggest_when: Vec, + pub children: Vec, +} + +impl CommandSpec { + /// Construct an active built-in command. + pub fn active( + path: &str, + summary: &str, + usage: &str, + category: CommandCategory, + keywords: &[&str], + ) -> Self { + Self::with_lifecycle( + path, + summary, + usage, + category, + keywords, + CommandLifecycle::Active, + ) + } + + /// Construct an advanced built-in command. + pub fn advanced( + path: &str, + summary: &str, + usage: &str, + category: CommandCategory, + keywords: &[&str], + ) -> Self { + Self::with_lifecycle( + path, + summary, + usage, + category, + keywords, + CommandLifecycle::Advanced, + ) + } + + /// Retain detailed replacement guidance for a removed path. + pub fn removed_with_details( + path: &str, + replacement: &str, + summary: &str, + usage: &str, + category: CommandCategory, + keywords: &[&str], + ) -> Self { + Self::with_lifecycle( + path, + summary, + usage, + category, + keywords, + CommandLifecycle::Removed { + replacement: replacement.to_string(), + }, + ) + } + + /// Construct metadata for an already-removed compatibility path. + pub fn removed(path: &str, replacement: &str, category: CommandCategory) -> Self { + Self::with_lifecycle( + path, + "Removed compatibility command", + &format!("Use /{replacement}"), + category, + &["legacy", "compatibility"], + CommandLifecycle::Removed { + replacement: replacement.to_string(), + }, + ) + } + + fn with_lifecycle( + path: &str, + summary: &str, + usage: &str, + category: CommandCategory, + keywords: &[&str], + lifecycle: CommandLifecycle, + ) -> Self { + Self { + path: path.to_string(), + aliases: Vec::new(), + summary: summary.to_string(), + usage: usage.to_string(), + category, + keywords: keywords + .iter() + .map(|keyword| (*keyword).to_string()) + .collect(), + lifecycle, + source: CommandSource::BuiltIn, + suggest_when: Vec::new(), + children: Vec::new(), + } + } + + /// Add accepted aliases to a focused command specification. + pub fn with_aliases(mut self, aliases: &[&str]) -> Self { + self.aliases = aliases.iter().map(|alias| (*alias).to_string()).collect(); + self + } + + /// Set the context signals that make this command useful. + pub fn with_suggestions(mut self, signals: &[CommandContextSignal]) -> Self { + self.suggest_when = signals.to_vec(); + self + } + + /// Attach canonical subcommand specifications. + pub fn with_children(mut self, children: Vec) -> Self { + self.children = children; + self + } + + /// Set the origin of this specification. + pub fn with_source(mut self, source: CommandSource) -> Self { + self.source = source; + self + } + + /// Return this command and all of its descendants in path order. + pub fn flattened(&self) -> Vec<&Self> { + let mut specs = vec![self]; + for child in &self.children { + specs.extend(child.flattened()); + } + specs + } +} + +/// Flatten a catalog, preserving parent-before-child order. +pub fn flatten_catalog(catalog: &[CommandSpec]) -> Vec<&CommandSpec> { + catalog.iter().flat_map(CommandSpec::flattened).collect() +} + +/// Combine catalog layers using slash-command execution precedence. +/// +/// A later layer loses the complete entry when either its canonical path or +/// any alias collides with an earlier canonical path or alias. +pub fn compose_catalogs( + builtin: Vec, + user_templates: Vec, + discovered_skills: Vec, + plugin_commands: Vec, +) -> Vec { + let mut seen = HashSet::new(); + let mut composed = Vec::new(); + + for spec in builtin + .into_iter() + .chain(user_templates) + .chain(discovered_skills) + .chain(plugin_commands) + { + let identifiers = spec + .flattened() + .into_iter() + .flat_map(|entry| { + std::iter::once(entry.path.as_str()).chain(entry.aliases.iter().map(String::as_str)) + }) + .collect::>(); + if identifiers + .iter() + .any(|identifier| seen.contains(*identifier)) + { + continue; + } + seen.extend(identifiers.into_iter().map(str::to_string)); + composed.push(spec); + } + + composed +} + +/// Find a canonical path or alias in a catalog. +pub fn find_catalog_spec<'a>(catalog: &'a [CommandSpec], path: &str) -> Option<&'a CommandSpec> { + let path = path.trim().trim_start_matches('/'); + flatten_catalog(catalog) + .into_iter() + .find(|spec| spec.path == path || spec.aliases.iter().any(|alias| alias == path)) +} + +/// Resolve a registered slash command name to its catalog category. +pub fn category_for_path(path: &str) -> CommandCategory { + find_catalog_spec(&builtin_catalog_template(), path) + .map(|spec| spec.category) + .unwrap_or(CommandCategory::Other) +} + +/// Return the stable user-facing category order. +pub const fn category_order() -> &'static [CommandCategory] { + CommandCategory::ordered() +} + +/// Render the checked-in reference from the same metadata used by command discovery. +pub fn render_reference() -> String { + let catalog = crate::builtin_catalog(); + let entries = flatten_catalog(&catalog); + let mut output = String::from("# Slash commands\n\nGenerated from the command catalog. Run `cargo run --package claurst-commands --example command_reference` from `src-rust/` to regenerate.\n\nType `/` for contextual suggestions, or open the command palette with Ctrl+K. Enter opens a command group; choose its Run entry to execute the parent. Use `/help ` for a command, including grouped paths. Suggestions use local session state and recent commands.\n"); + for category in category_order() { + output.push_str(&format!( + "\n## {}\n\n| Command | Usage | Description |\n|---|---|---|\n", + category.label() + )); + for entry in &entries { + if entry.category != *category + || matches!(entry.lifecycle, CommandLifecycle::Removed { .. }) + { + continue; + } + output.push_str(&format!( + "| `/{}` | `{}` | {} |\n", + entry.path, + entry.usage.replace('|', "\\|"), + entry.summary.replace('|', "\\|") + )); + } + } + output.push_str("\n## Removed paths\n\nThese paths no longer execute. Their replacement remains available.\n\n| Removed path | Use instead |\n|---|---|\n"); + for entry in &entries { + if let CommandLifecycle::Removed { replacement } = &entry.lifecycle { + for path in std::iter::once(&entry.path).chain(entry.aliases.iter()) { + output.push_str(&format!("| `/{path}` | `/{replacement}` |\n")); + } + } + } + output +} + +fn child( + parent: &str, + name: &str, + summary: &str, + category: CommandCategory, + keywords: &[&str], +) -> CommandSpec { + let arguments = match (parent, name) { + ("account", "login") => " [--console] [--codex] [--label ]", + ("account", "switch") => " [name]", + ("config", "theme") => " [name]", + ("config", "splash") => " [show|hide|toggle]", + ("config", "output-style") => " [style]", + ("config", "advisor") => " [model|off]", + ("effort", "fast") => " [on|off]", + ("session", "rename") => " ", + ("session", "resume") => " [id]", + ("session", "fork") => " [message_index]", + ("session", "add-dir") => " ", + ("session", "branch" | "tag") => " [arguments]", + ("familiar", "handoff") => " ", + ("familiar", "whisper") | ("thinking", "steer") => " ", + ("familiar", "incant" | "managed") => " [arguments]", + ("coven", "goal") => " [objective|status|pause|resume|clear|complete]", + ("review", "security" | "ultra") => " [path]", + ("rewind", "diff") => " [checkpoint]", + ("help", "feedback") => " [report]", + ("stash", "link" | "attach") | ("integrations", "mcp") => " [arguments]", + _ => "", + }; + CommandSpec::active( + &format!("{parent} {name}"), + summary, + &format!("/{parent} {name}{arguments}"), + category, + keywords, + ) +} + +/// Return pure built-in catalog metadata with fallback command summaries. +pub(crate) fn builtin_catalog_template() -> Vec { + use CommandCategory::{ + AccountModels, Conversation, CovenAdvanced, Customize, Extend, StartNavigate, WorkReview, + }; + use CommandContextSignal::{ + CovenDaemonAvailable, EmptySession, FamiliarAvailable, GitDirty, HasBackgroundTasks, + HasCompletedEdits, HighContextUsage, MissingAgentsFile, RunningTurn, Unauthenticated, + UpdateAvailable, + }; + + #[cfg(feature = "steer")] + let thinking_usage = "/thinking "; + #[cfg(not(feature = "steer"))] + let thinking_usage = "/thinking "; + #[cfg(feature = "steer")] + let thinking_keywords = &["thinking", "steer", "reasoning"]; + #[cfg(not(feature = "steer"))] + let thinking_keywords = &["thinking", "reasoning"]; + #[cfg(feature = "steer")] + let thinking_children = vec![ + child( + "thinking", + "steer", + "Steer a running turn", + Conversation, + &["steer"], + ) + .with_suggestions(&[RunningTurn]), + child( + "thinking", + "back", + "View earlier thinking traces", + Conversation, + &["back"], + ), + ]; + #[cfg(not(feature = "steer"))] + let thinking_children = vec![child( + "thinking", + "back", + "View earlier thinking traces", + Conversation, + &["back"], + )]; + + let catalog = vec![ + CommandSpec::advanced( + "find", + "Search the current transcript", + "/find", + Conversation, + &["transcript", "search"], + ), + CommandSpec::advanced( + "vim", + "Toggle Vim input mode", + "/vim", + Customize, + &["editing", "vim"], + ), + CommandSpec::advanced( + "voice", + "Toggle voice input", + "/voice", + Customize, + &["voice", "dictation"], + ), + CommandSpec::removed("cost", "usage cost", AccountModels), + CommandSpec::removed("context", "usage context", AccountModels), + CommandSpec::removed("stats", "usage stats", AccountModels), + CommandSpec::removed("rename", "session rename", StartNavigate), + CommandSpec::removed("changes", "diff", WorkReview), + CommandSpec::active( + "account", + "Manage accounts and provider connections", + "/account ", + AccountModels, + &["account", "login", "provider", "authentication"], + ) + .with_children(vec![ + child( + "account", + "login", + "Sign in to a provider", + AccountModels, + &["sign in", "auth"], + ) + .with_suggestions(&[Unauthenticated]), + child( + "account", + "logout", + "Sign out of a provider", + AccountModels, + &["sign out"], + ), + child( + "account", + "list", + "List configured accounts", + AccountModels, + &["accounts"], + ), + child( + "account", + "dedupe", + "Remove duplicate credentials", + AccountModels, + &["credentials"], + ), + child( + "account", + "providers", + "List available providers", + AccountModels, + &["providers"], + ), + child( + "account", + "connect", + "Connect a provider account", + AccountModels, + &["connect", "auth"], + ) + .with_suggestions(&[Unauthenticated, EmptySession, MissingAgentsFile]), + child( + "account", + "switch", + "Switch the active account", + AccountModels, + &["switch"], + ), + child( + "account", + "refresh", + "Refresh provider authentication", + AccountModels, + &["refresh"], + ), + ]), + CommandSpec::active( + "clear", + "Clear the conversation history", + "/clear", + StartNavigate, + &["clear", "reset", "new session"], + ) + .with_aliases(&["c", "reset", "new"]), + CommandSpec::active( + "commit", + "Create a git commit for completed work", + "/commit [message]", + WorkReview, + &["commit", "git", "changes"], + ) + .with_suggestions(&[GitDirty, HasCompletedEdits]), + CommandSpec::active( + "compact", + "Compact conversation history to reduce context usage", + "/compact", + Conversation, + &["compact", "context", "tokens"], + ) + .with_suggestions(&[HighContextUsage]), + CommandSpec::active( + "config", + "Configure Coven behavior and appearance", + "/config [show|get|set|unset|] ...", + Customize, + &["config", "settings", "customize"], + ) + .with_aliases(&["settings"]) + .with_children(vec![ + child( + "config", + "splash", + "Configure the empty-session splash screen", + Customize, + &["splash"], + ), + child( + "config", + "theme", + "Select the display theme", + Customize, + &["theme", "change theme"], + ), + child( + "config", + "output-style", + "Select the response output style", + Customize, + &["output"], + ), + child( + "config", + "keybindings", + "Open keybinding configuration", + Customize, + &["keys"], + ), + child( + "config", + "import", + "Import configuration from another tool", + Customize, + &["import"], + ), + child( + "config", + "advisor", + "Configure the advisor model", + Customize, + &["advisor"], + ), + ]), + CommandSpec::active( + "coven", + "Control Coven daemon features", + "/coven [status|sessions|familiars|log|run|daemon|goal|help] ...", + CovenAdvanced, + &["coven", "daemon", "advanced"], + ) + .with_suggestions(&[CovenDaemonAvailable]) + .with_children(vec![child( + "coven", + "goal", + "Manage durable session goals", + CovenAdvanced, + &["goal", "objective"], + )]), + CommandSpec::active( + "diff", + "Show uncommitted changes", + "/diff", + WorkReview, + &["diff", "git", "changes"], + ) + .with_suggestions(&[GitDirty, HasCompletedEdits]), + CommandSpec::active( + "effort", + "Configure model reasoning effort", + "/effort [low|normal|high|fast [on|off]]", + Customize, + &["effort", "reasoning", "speed"], + ) + .with_children(vec![child( + "effort", + "fast", + "Enable fast response mode", + Customize, + &["fast", "speed"], + )]), + CommandSpec::active( + "exit", + "Exit Coven", + "/exit", + StartNavigate, + &["exit", "quit"], + ) + .with_aliases(&["quit", "q"]), + CommandSpec::active( + "export", + "Export or share the current conversation", + "/export [copy [n]|share] [--format markdown|json] [--output ]", + Conversation, + &["export", "share", "copy"], + ) + .with_children(vec![ + child( + "export", + "share", + "Create a shareable session export", + Conversation, + &["share"], + ), + child( + "export", + "copy", + "Copy the session export to the clipboard", + Conversation, + &["copy"], + ), + ]), + CommandSpec::active( + "familiar", + "Work with Coven familiars", + "/familiar ", + CovenAdvanced, + &["familiar", "agent", "subagent"], + ) + .with_aliases(&["familiars", "agent"]) + .with_suggestions(&[EmptySession, FamiliarAvailable]) + .with_children(vec![ + child( + "familiar", + "incant", + "Run a familiar incantation", + CovenAdvanced, + &["incant"], + ), + child( + "familiar", + "whisper", + "Ask a familiar side question", + CovenAdvanced, + &["whisper"], + ) + .with_suggestions(&[FamiliarAvailable]), + child( + "familiar", + "handoff", + "Hand work to a familiar", + CovenAdvanced, + &["handoff"], + ) + .with_suggestions(&[FamiliarAvailable, CovenDaemonAvailable]), + child( + "familiar", + "list", + "List available familiars", + CovenAdvanced, + &["list", "agents"], + ), + child( + "familiar", + "managed", + "Manage familiar execution", + CovenAdvanced, + &["managed"], + ), + ]), + CommandSpec::active( + "help", + "Show available commands and usage information", + "/help [command]", + StartNavigate, + &["help", "commands", "usage"], + ) + .with_aliases(&["h", "?"]) + .with_suggestions(&[Unauthenticated, EmptySession]) + .with_children(vec![child( + "help", + "feedback", + "Submit feedback about Coven", + Extend, + &["feedback", "bug", "survey"], + )]), + CommandSpec::active( + "init", + "Initialize project instructions", + "/init", + StartNavigate, + &["init", "agents", "project"], + ) + .with_suggestions(&[EmptySession, MissingAgentsFile]), + CommandSpec::active( + "integrations", + "Configure external integrations", + "/integrations ", + Extend, + &["integrations", "mcp", "hooks"], + ) + .with_children(vec![ + child( + "integrations", + "hooks", + "Configure lifecycle hooks", + Extend, + &["hooks"], + ), + child( + "integrations", + "mcp", + "Manage MCP servers", + Extend, + &["mcp"], + ), + child( + "integrations", + "chrome", + "Connect Chrome integration", + Extend, + &["chrome"], + ), + ]), + CommandSpec::active( + "learn", + "Learn how to use Coven", + "/learn", + Extend, + &["learn", "guide", "tutorial"], + ) + .with_aliases(&["scribe"]), + CommandSpec::active( + "memory", + "Manage project instruction memory", + "/memory [edit|clear]", + WorkReview, + &["memory", "agents", "instructions"], + ), + CommandSpec::active( + "model", + "Select the active AI model", + "/model [name]", + AccountModels, + &["model", "provider", "ai"], + ) + .with_suggestions(&[EmptySession]), + CommandSpec::active( + "permissions", + "Configure command permissions", + "/permissions [sandbox]", + AccountModels, + &["permissions", "sandbox", "security"], + ) + .with_children(vec![child( + "permissions", + "sandbox", + "Configure sandbox permissions", + AccountModels, + &["sandbox"], + )]), + CommandSpec::active( + "plan", + "Create and manage implementation plans", + "/plan [prompt]", + Conversation, + &["plan", "planning"], + ), + CommandSpec::active( + "plugin", + "Manage installed plugins", + "/plugin [reload]", + Extend, + &["plugin", "extension"], + ) + .with_aliases(&["plugins"]) + .with_children(vec![child( + "plugin", + "reload", + "Reload installed plugins", + Extend, + &["reload", "plugin"], + ) + .with_aliases(&[ + "reload", + "plugin refresh", + "plugins reload", + "plugins refresh", + ])]), + CommandSpec::active( + "review", + "Review changes in the current project", + "/review [pr-comments]", + WorkReview, + &["review", "code", "git"], + ) + .with_suggestions(&[GitDirty, HasCompletedEdits]) + .with_children(vec![ + child( + "review", + "pr-comments", + "Read pull request comments", + WorkReview, + &["pull request", "comments"], + ), + child( + "review", + "security", + "Run a security-focused review", + WorkReview, + &["security", "vulnerabilities"], + ), + child( + "review", + "ultra", + "Run an exhaustive review", + WorkReview, + &["exhaustive", "review"], + ), + ]), + CommandSpec::active( + "rewind", + "Rewind or inspect recent changes", + "/rewind [list|diff|last]", + WorkReview, + &["rewind", "undo", "changes", "go back"], + ) + .with_suggestions(&[GitDirty, HasCompletedEdits]) + .with_children(vec![ + child( + "rewind", + "list", + "List recorded file-change checkpoints", + WorkReview, + &["checkpoints", "changes"], + ), + child( + "rewind", + "diff", + "Preview a recorded file-change diff", + WorkReview, + &["snapshot", "diff"], + ), + ]), + CommandSpec::active( + "search", + "Search saved sessions", + "/search ", + StartNavigate, + &["search", "sessions", "history"], + ), + CommandSpec::active( + "session", + "Manage the current session", + "/session ", + StartNavigate, + &["session", "resume", "branch"], + ) + .with_aliases(&["remote"]) + .with_children(vec![ + child( + "session", + "rename", + "Rename the current session", + StartNavigate, + &["rename", "title"], + ), + child( + "session", + "resume", + "Resume a saved session", + StartNavigate, + &["resume"], + ) + .with_suggestions(&[EmptySession]), + child( + "session", + "fork", + "Fork the current session", + StartNavigate, + &["fork"], + ), + child( + "session", + "branch", + "Create a session branch", + StartNavigate, + &["branch"], + ), + child( + "session", + "tag", + "Tag the current session", + StartNavigate, + &["tag"], + ), + child( + "session", + "add-dir", + "Add an allowed workspace directory", + StartNavigate, + &["directory"], + ), + ]), + CommandSpec::active( + "skills", + "Discover and run skills", + "/skills", + Extend, + &["skills", "commands", "extensions"], + ) + .with_aliases(&["skill"]), + CommandSpec::active( + "stash", + "Store useful links and attachments", + "/stash ", + CovenAdvanced, + &["stash", "link", "attachment"], + ) + .with_children(vec![ + child( + "stash", + "link", + "Store and manage links", + CovenAdvanced, + &["link"], + ), + child( + "stash", + "attach", + "Store and manage attachments", + CovenAdvanced, + &["attach"], + ), + ]), + CommandSpec::active( + "status", + "Show Coven and project status", + "/status [doctor]", + CovenAdvanced, + &["status", "diagnostics", "doctor"], + ) + .with_children(vec![child( + "status", + "doctor", + "Run diagnostic checks", + CovenAdvanced, + &["doctor", "diagnostics"], + )]), + CommandSpec::active( + "tasks", + "Manage background tasks", + "/tasks", + Conversation, + &["tasks", "background"], + ) + .with_aliases(&["bashes"]) + .with_suggestions(&[RunningTurn, HasBackgroundTasks]), + CommandSpec::active( + "thinking", + "Configure extended thinking or inspect traces", + thinking_usage, + Conversation, + thinking_keywords, + ) + .with_aliases(&["think"]) + .with_suggestions(&[RunningTurn]) + .with_children(thinking_children), + CommandSpec::active( + "update", + "Check for and install Coven updates", + "/update ", + CovenAdvanced, + &["update", "upgrade", "version"], + ) + .with_aliases(&["upgrade"]) + .with_suggestions(&[UpdateAvailable]) + .with_children(vec![ + child( + "update", + "version", + "Show the installed Coven version", + CovenAdvanced, + &["version"], + ), + child( + "update", + "notes", + "Show recent Coven release notes", + CovenAdvanced, + &["notes"], + ), + ]), + CommandSpec::active( + "usage", + "Show token, cost, and context usage", + "/usage [cost|context|stats]", + AccountModels, + &["usage", "tokens", "cost", "context"], + ) + .with_children(vec![ + child( + "usage", + "cost", + "Show session cost details", + AccountModels, + &["cost", "pricing"], + ), + child( + "usage", + "context", + "Show context window usage", + AccountModels, + &["context", "tokens"], + ) + .with_suggestions(&[HighContextUsage]), + child( + "usage", + "stats", + "Show detailed session statistics", + AccountModels, + &["statistics", "tokens"], + ), + ]), + CommandSpec::removed_with_details( + "version", + "update version", + "Show the installed Coven version", + "/version", + CovenAdvanced, + &["version", "update"], + ) + .with_aliases(&["v"]), + CommandSpec::removed_with_details( + "release-notes", + "update notes", + "Show recent Coven highlights", + "/release-notes", + CovenAdvanced, + &["release notes", "changelog"], + ) + .with_aliases(&["whats-new", "changelog"]), + CommandSpec::removed_with_details( + "resume", + "session resume", + "Resume a saved session", + "/resume", + StartNavigate, + &["resume", "continue"], + ) + .with_aliases(&["r", "continue"]), + CommandSpec::removed("reload-plugins", "plugin reload", Extend), + CommandSpec::removed("doctor", "status doctor", CovenAdvanced), + CommandSpec::removed_with_details( + "login", + "account login", + "Sign in to a provider", + "/login", + AccountModels, + &["login"], + ), + CommandSpec::removed_with_details( + "logout", + "account logout", + "Sign out of a provider", + "/logout", + AccountModels, + &["logout"], + ), + CommandSpec::removed_with_details( + "accounts", + "account list", + "List configured accounts", + "/accounts", + AccountModels, + &["accounts"], + ) + .with_children(vec![CommandSpec::removed_with_details( + "accounts dedupe", + "account dedupe", + "Remove duplicate account profiles", + "/accounts dedupe", + AccountModels, + &["accounts", "dedupe"], + )]), + CommandSpec::removed("switch", "account switch", AccountModels), + CommandSpec::removed("refresh", "account refresh", AccountModels), + CommandSpec::removed_with_details( + "incant", + "familiar incant", + "Run a familiar incantation", + "/incant", + CovenAdvanced, + &["incant"], + ), + CommandSpec::removed_with_details( + "handoff", + "familiar handoff", + "Hand work to a familiar", + "/handoff", + CovenAdvanced, + &["handoff", "familiar"], + ), + CommandSpec::removed_with_details( + "hooks", + "integrations hooks", + "Configure lifecycle hooks", + "/hooks", + Extend, + &["hooks"], + ), + CommandSpec::removed("import-config", "config import", Customize), + CommandSpec::removed_with_details( + "mcp", + "integrations mcp", + "Manage MCP servers", + "/mcp", + Extend, + &["mcp"], + ), + CommandSpec::removed("fork", "session fork", StartNavigate), + CommandSpec::removed("theme", "config theme", Customize), + CommandSpec::removed("output-style", "config output-style", Customize), + CommandSpec::removed("keybindings", "config keybindings", Customize), + CommandSpec::removed_with_details( + "splash", + "config splash", + "Configure splash screen", + "/splash", + Customize, + &["splash"], + ) + .with_aliases(&["welcome-screen"]), + CommandSpec::removed("share", "export share", Conversation), + CommandSpec::removed("add-dir", "session add-dir", StartNavigate), + CommandSpec::removed("agents", "familiar list", CovenAdvanced), + CommandSpec::removed("branch", "session branch", StartNavigate), + CommandSpec::removed("tag", "session tag", StartNavigate), + CommandSpec::removed("pr-comments", "review pr-comments", WorkReview), + CommandSpec::removed("copy", "export copy", Conversation), + CommandSpec::removed_with_details( + "chrome", + "integrations chrome", + "Connect Chrome integration", + "/chrome", + Extend, + &["chrome"], + ), + CommandSpec::removed("fast", "effort fast", Customize).with_aliases(&["speed"]), + CommandSpec::removed("think-back", "thinking back", Conversation) + .with_aliases(&["thinkback"]), + #[cfg(feature = "steer")] + CommandSpec::removed_with_details( + "steer", + "thinking steer", + "Steer a running turn", + "/steer", + Conversation, + &["steer", "thinking"], + ), + CommandSpec::removed_with_details( + "whisper", + "familiar whisper", + "Ask a familiar side question", + "/whisper", + CovenAdvanced, + &["whisper"], + ) + .with_aliases(&["btw"]), + CommandSpec::removed_with_details( + "sandbox", + "permissions sandbox", + "Configure sandbox permissions", + "/sandbox", + AccountModels, + &["sandbox"], + ) + .with_aliases(&["sandbox-toggle"]), + CommandSpec::removed("advisor", "config advisor", Customize), + CommandSpec::removed_with_details( + "providers", + "account providers", + "List available providers", + "/providers", + AccountModels, + &["providers"], + ), + CommandSpec::removed_with_details( + "connect", + "account connect", + "Connect a provider account", + "/connect", + AccountModels, + &["connect"], + ), + CommandSpec::removed("managed-agents", "familiar managed", CovenAdvanced), + CommandSpec::removed("goal", "coven goal", CovenAdvanced), + CommandSpec::removed_with_details( + "link", + "stash link", + "Store and manage links", + "/link", + CovenAdvanced, + &["link"], + ) + .with_aliases(&["links"]), + CommandSpec::removed_with_details( + "attach", + "stash attach", + "Store and manage attachments", + "/attach", + CovenAdvanced, + &["attach"], + ) + .with_aliases(&["attachments"]), + CommandSpec::removed_with_details( + "feedback", + "help feedback", + "Submit feedback about Coven", + "/feedback [report]", + Extend, + &["feedback", "bug"], + ) + .with_aliases(&["bug", "survey"]), + ]; + + catalog +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, HashSet}; + + #[test] + fn active_canonical_paths_are_unique_and_fully_described() { + let catalog = builtin_catalog_template(); + let primary_paths = catalog + .iter() + .filter(|spec| matches!(spec.lifecycle, CommandLifecycle::Active)) + .map(|spec| spec.path.as_str()) + .collect::>(); + let expected_primaries = [ + "account", + "clear", + "commit", + "compact", + "config", + "coven", + "diff", + "effort", + "exit", + "export", + "familiar", + "help", + "init", + "integrations", + "learn", + "memory", + "model", + "permissions", + "plan", + "plugin", + "review", + "rewind", + "search", + "session", + "skills", + "stash", + "status", + "tasks", + "thinking", + "update", + "usage", + ] + .into_iter() + .collect::>(); + assert_eq!(primary_paths.len(), 31); + assert_eq!(primary_paths, expected_primaries); + + for spec in flatten_catalog(&catalog) + .into_iter() + .filter(|spec| matches!(spec.lifecycle, CommandLifecycle::Active)) + { + assert!(!spec.summary.is_empty(), "{} has no summary", spec.path); + assert!(!spec.usage.is_empty(), "{} has no usage", spec.path); + assert!(!spec.keywords.is_empty(), "{} has no keywords", spec.path); + assert_ne!(spec.category, CommandCategory::Other); + } + } + + #[test] + fn removed_replacements_resolve_to_live_paths_including_children() { + let catalog = builtin_catalog_template(); + let flattened = flatten_catalog(&catalog); + let live_paths = flattened + .iter() + .filter(|spec| { + matches!( + spec.lifecycle, + CommandLifecycle::Active | CommandLifecycle::Advanced + ) + }) + .map(|spec| spec.path.as_str()) + .collect::>(); + + for spec in &flattened { + let replacement = match &spec.lifecycle { + CommandLifecycle::Removed { replacement } => replacement, + CommandLifecycle::Active | CommandLifecycle::Advanced => continue, + }; + assert!( + live_paths.contains(replacement.as_str()), + "{} replacement {replacement} is not live", + spec.path + ); + } + } + + #[test] + fn category_order_is_stable() { + assert_eq!( + CommandCategory::ordered(), + &[ + CommandCategory::StartNavigate, + CommandCategory::WorkReview, + CommandCategory::Conversation, + CommandCategory::AccountModels, + CommandCategory::Customize, + CommandCategory::Extend, + CommandCategory::CovenAdvanced, + ] + ); + assert_eq!( + CommandCategory::ordered() + .iter() + .map(|category| category.label()) + .collect::>(), + vec![ + "Start & Navigate", + "Work & Review", + "Conversation", + "Account & Models", + "Customize", + "Extend", + "Coven & Advanced", + ] + ); + } + + #[test] + fn flattened_canonical_paths_are_unique() { + let catalog = builtin_catalog_template(); + let mut paths = HashSet::new(); + + for spec in flatten_catalog(&catalog) { + assert!( + paths.insert(spec.path.as_str()), + "duplicate flattened path: {}", + spec.path + ); + } + } + + #[test] + fn compose_catalogs_preserves_execution_precedence() { + let builtin = vec![CommandSpec::active( + "review", + "Review changes", + "/review", + CommandCategory::WorkReview, + &["review"], + )]; + let user = vec![CommandSpec::active( + "deploy", + "Deploy from a user template", + "/deploy", + CommandCategory::Extend, + &["deploy"], + ) + .with_source(CommandSource::User)]; + let skill = vec![ + CommandSpec::active( + "deploy", + "Deploy from a discovered skill", + "/deploy", + CommandCategory::Extend, + &["deploy"], + ) + .with_source(CommandSource::Skill), + CommandSpec::active( + "audit", + "Audit from a discovered skill", + "/audit", + CommandCategory::Extend, + &["audit"], + ) + .with_source(CommandSource::Skill), + ]; + let plugin = vec![ + CommandSpec::active( + "audit", + "Audit from a plugin", + "/audit", + CommandCategory::Extend, + &["audit"], + ) + .with_source(CommandSource::Plugin), + CommandSpec::active( + "plugin-inspect", + "Inspect from a plugin", + "/plugin-inspect", + CommandCategory::Extend, + &["inspect"], + ) + .with_aliases(&["review"]) + .with_source(CommandSource::Plugin), + CommandSpec::active( + "release", + "Release from a plugin", + "/release", + CommandCategory::Extend, + &["release"], + ) + .with_source(CommandSource::Plugin), + ]; + + let composed = compose_catalogs(builtin, user, skill, plugin); + let sources = composed + .iter() + .map(|spec| (spec.path.as_str(), spec.source)) + .collect::>(); + + assert_eq!(sources.get("review"), Some(&CommandSource::BuiltIn)); + assert_eq!(sources.get("deploy"), Some(&CommandSource::User)); + assert_eq!(sources.get("audit"), Some(&CommandSource::Skill)); + assert_eq!(sources.get("release"), Some(&CommandSource::Plugin)); + assert!(!sources.contains_key("plugin-inspect")); + } + + #[test] + fn visible_registered_commands_have_metadata() { + let catalog = crate::builtin_catalog(); + let flattened = flatten_catalog(&catalog); + + for command in crate::all_commands() + .iter() + .filter(|command| !command.hidden()) + { + let spec = flattened + .iter() + .find(|spec| spec.path == command.name()) + .unwrap_or_else(|| { + panic!( + "visible command /{} has no catalog metadata", + command.name() + ) + }); + assert_eq!( + spec.summary, + crate::SlashCommand::description(command.as_ref()), + "visible command /{} summary does not match its description", + command.name() + ); + } + } + + #[test] + fn context_suggestions_cover_the_expected_command_paths() { + use claurst_core::CommandContextSignal::*; + + let catalog = builtin_catalog_template(); + let suggested_paths = |signal| { + flatten_catalog(&catalog) + .into_iter() + .filter(|spec| spec.suggest_when.contains(&signal)) + .map(|spec| spec.path.as_str()) + .collect::>() + }; + + assert_eq!( + suggested_paths(Unauthenticated), + HashSet::from(["account connect", "account login", "help"]) + ); + assert_eq!( + suggested_paths(EmptySession), + HashSet::from([ + "account connect", + "session resume", + "init", + "model", + "familiar", + "help", + ]) + ); + assert_eq!( + suggested_paths(MissingAgentsFile), + HashSet::from(["account connect", "init"]) + ); + assert_eq!( + suggested_paths(GitDirty), + HashSet::from(["diff", "review", "commit", "rewind"]) + ); + assert_eq!( + suggested_paths(HasCompletedEdits), + HashSet::from(["review", "diff", "commit", "rewind"]) + ); + #[cfg(feature = "steer")] + let running_turn_paths = HashSet::from(["thinking steer", "tasks", "thinking"]); + #[cfg(not(feature = "steer"))] + let running_turn_paths = HashSet::from(["tasks", "thinking"]); + assert_eq!(suggested_paths(RunningTurn), running_turn_paths); + assert_eq!( + suggested_paths(HighContextUsage), + HashSet::from(["usage context", "compact"]) + ); + assert_eq!( + suggested_paths(HasBackgroundTasks), + HashSet::from(["tasks"]) + ); + assert_eq!(suggested_paths(UpdateAvailable), HashSet::from(["update"])); + assert_eq!( + suggested_paths(FamiliarAvailable), + HashSet::from(["familiar", "familiar handoff", "familiar whisper"]) + ); + assert_eq!( + suggested_paths(CovenDaemonAvailable), + HashSet::from(["coven", "familiar handoff"]) + ); + } +} diff --git a/src-rust/crates/commands/src/grouped_commands.rs b/src-rust/crates/commands/src/grouped_commands.rs new file mode 100644 index 00000000..8c9ed94c --- /dev/null +++ b/src-rust/crates/commands/src/grouped_commands.rs @@ -0,0 +1,138 @@ +use crate::{ + AccountsCommand, AttachCommand, ChromeCommand, CommandContext, CommandResult, ConnectCommand, + HooksCommand, LinkCommand, LoginCommand, LogoutCommand, McpCommand, ProvidersCommand, + RefreshCommand, SlashCommand, SwitchCommand, +}; +use async_trait::async_trait; + +/// Split a parent command's arguments into its child name and remaining input. +pub(crate) fn split_subcommand(args: &str) -> (&str, &str) { + let args = args.trim(); + match args.split_once(char::is_whitespace) { + Some((subcommand, rest)) => (subcommand, rest.trim()), + None => (args, ""), + } +} + +fn unknown_child(parent: &str, child: &str, usage: &str, valid_children: &[&str]) -> CommandResult { + CommandResult::Error(format!( + "Unknown /{parent} subcommand '{child}'.\nValid children: {}.\nUsage: {usage}", + valid_children.join(", ") + )) +} + +pub struct AccountCommand; + +#[async_trait] +impl SlashCommand for AccountCommand { + fn name(&self) -> &str { + "account" + } + + fn description(&self) -> &str { + "Manage accounts and provider connections" + } + + fn help(&self) -> &str { + "Usage: /account " + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + let (child, remaining_args) = split_subcommand(args); + match child { + "" | "list" => AccountsCommand.execute("", ctx).await, + "dedupe" => AccountsCommand.execute("dedupe", ctx).await, + "login" => LoginCommand.execute(remaining_args, ctx).await, + "logout" => LogoutCommand.execute(remaining_args, ctx).await, + "connect" => ConnectCommand.execute(remaining_args, ctx).await, + "providers" => ProvidersCommand.execute(remaining_args, ctx).await, + "switch" => SwitchCommand.execute(remaining_args, ctx).await, + "refresh" => RefreshCommand.execute(remaining_args, ctx).await, + unknown => unknown_child( + "account", + unknown, + self.help(), + &[ + "login", + "logout", + "list", + "dedupe", + "providers", + "connect", + "switch", + "refresh", + ], + ), + } + } +} + +pub struct IntegrationsCommand; + +#[async_trait] +impl SlashCommand for IntegrationsCommand { + fn name(&self) -> &str { + "integrations" + } + + fn description(&self) -> &str { + "Configure external integrations" + } + + fn help(&self) -> &str { + "Usage: /integrations " + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + let (child, remaining_args) = split_subcommand(args); + match child { + "" | "list" => CommandResult::Message(format!( + "{}\n\nValid children: hooks, mcp, chrome", + self.help() + )), + "hooks" => HooksCommand.execute(remaining_args, ctx).await, + "mcp" => McpCommand.execute(remaining_args, ctx).await, + "chrome" => ChromeCommand.execute(remaining_args, ctx).await, + unknown => unknown_child( + "integrations", + unknown, + self.help(), + &["hooks", "mcp", "chrome"], + ), + } + } +} + +pub struct StashCommand; + +#[async_trait] +impl SlashCommand for StashCommand { + fn name(&self) -> &str { + "stash" + } + + fn description(&self) -> &str { + "Store useful links and attachments" + } + + fn help(&self) -> &str { + "Usage: /stash \n\n\ + /stash link [arguments] — store and manage links\n\ + /stash attach [arguments] — store and manage attachments" + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + let (child, remaining_args) = split_subcommand(args); + match child { + "" | "list" => CommandResult::Message(self.help().to_string()), + "link" | "links" => LinkCommand.execute(remaining_args, ctx).await, + "attach" | "attachments" => AttachCommand.execute(remaining_args, ctx).await, + unknown => unknown_child( + "stash", + unknown, + self.help(), + &["link", "links", "attach", "attachments"], + ), + } + } +} diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 573a80bf..d9643a8e 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -281,6 +281,9 @@ pub struct SnapshotDiffCommand; pub struct ProvidersCommand; pub struct ConnectCommand; pub struct FamiliarCommand; +pub struct HandoffCommand; +#[cfg(feature = "steer")] +pub struct SteerCommand; pub struct SearchCommand; pub struct ForkCommand; pub struct LinkCommand; @@ -484,13 +487,6 @@ fn execute_named_command_from_slash( // ---- /help --------------------------------------------------------------- -/// Category labels for help grouping. Delegates to the canonical -/// categorization in `claurst-tui` so the `/help` text output, the F1 help -/// overlay, and the command palette all group commands identically. -fn command_category(name: &str) -> &'static str { - claurst_tui::app::slash_command_category(name) -} - #[async_trait] impl SlashCommand for HelpCommand { fn name(&self) -> &str { @@ -503,93 +499,63 @@ impl SlashCommand for HelpCommand { "Show available commands and usage information" } - async fn execute(&self, args: &str, _ctx: &mut CommandContext) -> CommandResult { - if !args.is_empty() { - // Show help for a specific command - if let Some(cmd) = find_command(args) { - let aliases = cmd.aliases(); - let alias_line = if aliases.is_empty() { - String::new() - } else { - format!( - "\nAliases: {}", - aliases - .iter() - .map(|a| format!("/{}", a)) - .collect::>() - .join(", ") - ) - }; + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + let args = args.trim().trim_start_matches('/'); + if let Some(report) = args + .strip_prefix("feedback") + .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + return BugCommand.execute(report.trim(), ctx).await; + } + let builtin = builtin_catalog(); + if let Some(spec) = catalog::find_catalog_spec(&builtin, args) { + if let catalog::CommandLifecycle::Removed { replacement } = &spec.lifecycle { return CommandResult::Message(format!( - "/{name}{aliases}\n{desc}\n\n{help}", - name = cmd.name(), - aliases = alias_line, - desc = cmd.description(), - help = cmd.help(), + "/{} was removed; use /{}", + args, replacement )); } - return CommandResult::Error(format!("Unknown command: /{}", args)); } - - // Grouped output - let commands = all_commands(); - let visible: Vec<_> = commands.iter().filter(|c| !c.hidden()).collect(); - - // Collect categories in stable order - let category_order = [ - "Conversation", - "Settings", - "Usage & Cost", - "System", - "Auth & Permissions", - "Project", - "Integrations", - "Sessions & Remote", - "AI & Thinking", - "Tools & Extras", - "Coven", - "General", - "Other", - ]; - - let mut by_cat: std::collections::HashMap<&str, Vec> = - std::collections::HashMap::new(); - - for cmd in &visible { - let cat = command_category(cmd.name()); - let aliases = cmd.aliases(); - let alias_str = if aliases.is_empty() { - String::new() - } else { - format!( - " ({})", - aliases - .iter() - .map(|a| format!("/{}", a)) - .collect::>() - .join(", ") - ) + let plugin_commands = claurst_plugins::global_plugin_registry() + .map(|registry| registry.all_command_defs()) + .unwrap_or_default(); + let catalog = composed_catalog(&ctx.config, &ctx.working_dir, &plugin_commands); + if !args.is_empty() { + let entry = claurst_tui::flatten_command_catalog(&catalog) + .into_iter() + .find(|entry| { + entry.path == args || entry.aliases.iter().any(|alias| alias == args) + }); + // The catalog owns discovery. Retain handler details for argument-rich + // commands whose complete grammar is not a palette navigation tree. + let root = args.split_whitespace().next().unwrap_or(args); + let details = find_command(root).map(|command| command.help()); + return match (entry, details) { + (Some(entry), details) => { + let mut help = format!( + "/{}\n{}\n\nUsage: {}", + entry.path, entry.summary, entry.usage + ); + if let Some(details) = details.filter(|details| !details.is_empty()) { + help.push_str("\n\n"); + help.push_str(details); + } + CommandResult::Message(help) + } + (None, Some(details)) => CommandResult::Message(details.to_string()), + (None, None) => CommandResult::Error(format!("Unknown command: /{args}")), }; - by_cat.entry(cat).or_default().push(format!( - " /{:<20} {}", - format!("{}{}", cmd.name(), alias_str), - cmd.description() - )); } - let mut output = String::from("Coven — Slash Commands\n"); - output.push_str("════════════════════════════\n"); - - for cat in &category_order { - if let Some(entries) = by_cat.get(cat) { - output.push_str(&format!("\n{}\n", cat)); - for entry in entries { - output.push_str(&format!("{}\n", entry)); - } + let mut category = ""; + for entry in &catalog { + if entry.category != category { + category = &entry.category; + output.push_str(&format!("\n{category}\n")); } + output.push_str(&format!(" /{:<20} {}\n", entry.path, entry.summary)); } - - output.push_str("\nType /help for detailed help on a specific command."); + output.push_str("\nType /help for usage, including grouped paths such as /help account login."); CommandResult::Message(output) } } @@ -833,6 +799,7 @@ impl SlashCommand for ConfigCommand { /config statusline [show|hide] [cost|tokens|model|time|all]\n\ /config terminal-setup\n\ /config theme [name] — theme picker / set theme\n\ + /config splash [show|hide|toggle] — configure the welcome screen\n\ /config keybindings — open keybindings.json\n\ /config output-style [s] — show or set the output style\n\ /config import — import settings from ~/.claude\n\ @@ -850,9 +817,7 @@ impl SlashCommand for ConfigCommand { )); } - let mut subcommand_parts = args.splitn(2, char::is_whitespace); - let subcommand = subcommand_parts.next().unwrap_or_default(); - let subcommand_args = subcommand_parts.next().unwrap_or_default().trim(); + let (subcommand, subcommand_args) = grouped_commands::split_subcommand(args); match subcommand { "color" | "prompt-color" | "prompt_color" => { return ColorCommand.execute(subcommand_args, ctx).await; @@ -872,11 +837,13 @@ impl SlashCommand for ConfigCommand { } return TerminalSetupCommand.execute("", ctx).await; } - // Folded in Phase 3 (issue #73); the old top-level names stay - // hidden one-release aliases. + // Canonical config areas delegate to their retained implementations. "keybindings" | "keybinding" => { return KeybindingsCommand.execute(subcommand_args, ctx).await; } + "splash" => { + return SplashCommand.execute(subcommand_args, ctx).await; + } "theme" => { return ThemeCommand.execute(subcommand_args, ctx).await; } @@ -1129,6 +1096,9 @@ impl SlashCommand for SplashCommand { fn name(&self) -> &str { "splash" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["welcome-screen"] @@ -1373,6 +1343,9 @@ impl SlashCommand for VersionCommand { fn name(&self) -> &str { "version" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["v"] } @@ -1392,6 +1365,9 @@ impl SlashCommand for ReleaseNotesCommand { fn name(&self) -> &str { "release-notes" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["whats-new", "changelog"] } @@ -1421,6 +1397,9 @@ impl SlashCommand for ResumeCommand { fn name(&self) -> &str { "resume" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["r", "continue"] } @@ -1470,8 +1449,7 @@ impl SlashCommand for StatusCommand { } async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { - // /status doctor absorbs the former standalone /doctor command - // (issue #73); the old name stays a hidden one-release alias. + // /status doctor owns the retained diagnostic implementation. let trimmed = args.trim(); if let Some(rest) = trimmed.strip_prefix("doctor") { return DoctorCommand.execute(rest.trim(), ctx).await; @@ -1663,20 +1641,20 @@ impl SlashCommand for GoalCommand { } fn help(&self) -> &str { "Usage:\n\ - /goal — set a new goal and begin working autonomously\n\ - /goal --tokens 250K — set a goal with a soft token budget\n\ - /goal — show current goal status\n\ - /goal status — show current goal status\n\ - /goal pause — pause the active goal\n\ - /goal resume — resume a paused goal\n\ - /goal clear — delete the current goal\n\ - /goal complete — request a completion audit\n\n\ + /coven goal — set a new goal and begin working autonomously\n\ + /coven goal --tokens 250K — set a goal with a soft token budget\n\ + /coven goal — show current goal status\n\ + /coven goal status — show current goal status\n\ + /coven goal pause — pause the active goal\n\ + /coven goal resume — resume a paused goal\n\ + /coven goal clear — delete the current goal\n\ + /coven goal complete — request a completion audit\n\n\ Goals let Coven work autonomously across turns toward a single\n\ verifiable objective. Coven will keep iterating until the goal is\n\ complete, you pause it, or the 200-turn runaway guard fires.\n\n\ Examples:\n\ - /goal Migrate the project from Express to Fastify, keeping all routes passing\n\ - /goal --tokens 500K Fix all TypeScript errors in src/ without breaking tests" + /coven goal Migrate the project from Express to Fastify, keeping all routes passing\n\ + /coven goal --tokens 500K Fix all TypeScript errors in src/ without breaking tests" } async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { @@ -1705,7 +1683,8 @@ impl SlashCommand for GoalCommand { } Some(g) if g.status == claurst_core::GoalStatus::Paused => { return CommandResult::Message( - "Goal is already paused. Use /goal resume to continue.".to_string(), + "Goal is already paused. Use /coven goal resume to continue." + .to_string(), ); } _ => {} @@ -1714,7 +1693,7 @@ impl SlashCommand for GoalCommand { return CommandResult::Error(format!("Failed to pause goal: {}", e)); } return CommandResult::Message( - "Goal paused. Use /goal resume to continue.".to_string(), + "Goal paused. Use /coven goal resume to continue.".to_string(), ); } "resume" => { @@ -1729,7 +1708,8 @@ impl SlashCommand for GoalCommand { } Some(g) if g.status == claurst_core::GoalStatus::Complete => { return CommandResult::Message( - "Goal is complete. Use /goal to set a new one.".to_string(), + "Goal is complete. Use /coven goal to set a new one." + .to_string(), ); } _ => {} @@ -1758,7 +1738,7 @@ impl SlashCommand for GoalCommand { match store.get_active_goal(session_id) { None => { return CommandResult::Message( - "No active goal. Set one with /goal .".to_string(), + "No active goal. Set one with /coven goal .".to_string(), ); } Some(goal) => { @@ -1796,8 +1776,8 @@ impl SlashCommand for GoalCommand { if objective.is_empty() { return CommandResult::Message( - "Usage: /goal [--tokens 250K]\n\ - Or: /goal status|pause|resume|clear|complete" + "Usage: /coven goal [--tokens 250K]\n\ + Or: /coven goal status|pause|resume|clear|complete" .to_string(), ); } @@ -1832,9 +1812,9 @@ fn goal_status(session_id: &str) -> CommandResult { None => return CommandResult::Error("Could not open goal store.".to_string()), }; match store.get_goal(session_id) { - None => { - CommandResult::Message("No active goal. Set one with:\n /goal ".to_string()) - } + None => CommandResult::Message( + "No active goal. Set one with:\n /coven goal ".to_string(), + ), Some(g) => { let budget_line = g .budget_display() @@ -2074,8 +2054,11 @@ impl SlashCommand for BugCommand { fn name(&self) -> &str { "feedback" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { - vec!["bug"] + vec!["bug", "survey"] } fn description(&self) -> &str { "Submit feedback about Coven" @@ -2901,6 +2884,9 @@ impl SlashCommand for LoginCommand { fn name(&self) -> &str { "login" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Authenticate with Anthropic or Codex (multi-account)" } @@ -2917,13 +2903,10 @@ impl SlashCommand for LoginCommand { } async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { - // Subcommands absorbed from the former /switch and /refresh commands - // (issue #73); both stay as hidden one-release aliases. + // Retain the historical nested forms while canonical account routing + // owns the former standalone /switch and /refresh commands. let trimmed = args.trim(); - let (head, rest) = match trimmed.split_once(char::is_whitespace) { - Some((h, r)) => (h, r.trim()), - None => (trimmed, ""), - }; + let (head, rest) = grouped_commands::split_subcommand(trimmed); match head { "switch" => return SwitchCommand.execute(rest, ctx).await, "refresh" => return RefreshCommand.execute(rest, ctx).await, @@ -2968,6 +2951,9 @@ impl SlashCommand for LogoutCommand { fn name(&self) -> &str { "logout" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Clear credentials for the active account" } @@ -3052,6 +3038,9 @@ impl SlashCommand for AccountsCommand { fn name(&self) -> &str { "accounts" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "List stored Anthropic and Codex accounts" } @@ -3223,6 +3212,9 @@ impl SlashCommand for IncantCommand { fn name(&self) -> &str { "incant" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Cast a speech incantation — change the model's voice (caveman, rocky) or lift it" } @@ -3547,8 +3539,7 @@ impl SlashCommand for ReviewCommand { match head { "security" => return SecurityReviewCommand.execute(rest, ctx).await, "ultra" | "ultrareview" => return UltrareviewCommand.execute(rest, ctx).await, - // /review comments absorbs the former standalone /pr-comments - // command (issue #73); the old name stays a hidden alias. + // /review comments owns the retained pull-request implementation. "comments" | "pr-comments" => { return execute_named_command_from_slash("pr-comments", rest, ctx) } @@ -3913,6 +3904,9 @@ impl SlashCommand for HooksCommand { fn name(&self) -> &str { "hooks" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Show configured event hooks" } @@ -3957,6 +3951,9 @@ impl SlashCommand for McpCommand { fn name(&self) -> &str { "mcp" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Show MCP server status and manage connections" } @@ -4663,13 +4660,14 @@ impl SlashCommand for PermissionsCommand { "View or change tool permission settings" } fn help(&self) -> &str { - "Usage: /permissions [set |allow |deny |reset]\n\n\ + "Usage: /permissions [set |allow |deny |sandbox|reset]\n\n\ Modes: default, accept-edits, bypass-permissions, plan\n\n\ Examples:\n\ /permissions — show current permissions\n\ /permissions set accept-edits — auto-accept file edits\n\ /permissions allow Bash — allow a specific tool\n\ /permissions deny Write — deny a specific tool\n\ + /permissions sandbox [on|off] — configure sandboxed execution\n\ /permissions reset — clear overrides" } @@ -4700,11 +4698,10 @@ impl SlashCommand for PermissionsCommand { )); } - let mut parts = args.splitn(2, ' '); - let sub = parts.next().unwrap_or("").trim(); - let arg = parts.next().unwrap_or("").trim(); + let (sub, arg) = grouped_commands::split_subcommand(args); match sub { + "sandbox" => SandboxToggleCommand.execute(arg, ctx).await, "set" => { let mode = match arg.to_lowercase().as_str() { "default" => claurst_core::config::PermissionMode::Default, @@ -4871,32 +4868,30 @@ impl SlashCommand for SessionCommand { "Show or manage conversation sessions" } fn help(&self) -> &str { - "Usage: /session [list|rename |fork [name]|branch ...|tag ...|add-dir ]\n\n\ + "Usage: /session [list|rename |resume [id]|fork [name]|branch ...|tag ...|add-dir ]\n\n\ Without arguments, shows the current session (or the active remote URL).\n\n\ Subcommands:\n\ /session list — list saved sessions\n\ /session rename — rename the current session\n\ + /session resume [id] — resume a saved session\n\ /session fork [name] — fork the session into a new branch\n\ /session branch ... — create/switch/list conversation branches\n\ /session tag ... — toggle searchable session tags\n\ /session add-dir — add a workspace root to the session\n\n\ - The legacy /fork, /branch, /tag, and /add-dir commands remain hidden\n\ - one-release compatibility aliases." + Former /fork, /branch, /tag, and /add-dir roots now point here." } async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { let trimmed = args.trim(); // /session rename [...] absorbs the former standalone /rename command. - if let Some(rest) = trimmed.strip_prefix("rename") { - return RenameCommand.execute(rest.trim(), ctx).await; + let (head, rest) = grouped_commands::split_subcommand(trimmed); + if head == "rename" { + return RenameCommand.execute(rest, ctx).await; } // Subcommands absorbed from the former /fork, /branch, /tag, and // /add-dir commands (issue #73). - let (head, rest) = match trimmed.split_once(char::is_whitespace) { - Some((h, r)) => (h, r.trim()), - None => (trimmed, ""), - }; match head { + "resume" => return ResumeCommand.execute(rest, ctx).await, "fork" => return ForkCommand.execute(rest, ctx).await, "branch" => return execute_named_command_from_slash("branch", rest, ctx), "tag" => return execute_named_command_from_slash("tag", rest, ctx), @@ -5061,18 +5056,35 @@ impl SlashCommand for ThinkingCommand { } fn help(&self) -> &str { - "Usage: /thinking [back ...]\n\n\ - Shows extended-thinking availability for the active model.\n\ - /thinking back [play] shows extended-thinking traces from previous\n\ - responses (absorbing the former /think-back command)." + #[cfg(feature = "steer")] + { + "Usage: /thinking [back ...|steer ]\n\n\ + Shows extended-thinking availability for the active model.\n\ + /thinking back [play] shows extended-thinking traces from previous\n\ + responses (absorbing the former /think-back command).\n\ + /thinking steer queues guidance for a running turn in the TUI." + } + #[cfg(not(feature = "steer"))] + { + "Usage: /thinking [back ...]\n\n\ + Shows extended-thinking availability for the active model.\n\ + /thinking back [play] shows extended-thinking traces from previous\n\ + responses (absorbing the former /think-back command)." + } } async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { - // /thinking back absorbs the former standalone /think-back command - // (issue #73); the old name stays a hidden one-release alias. + // /thinking back owns the retained thinking-trace implementation. let trimmed = args.trim(); - if let Some(rest) = trimmed.strip_prefix("back") { - return ThinkBackCommand.execute(rest.trim(), ctx).await; + let (subcommand, remaining_args) = grouped_commands::split_subcommand(trimmed); + if subcommand == "back" { + return ThinkBackCommand.execute(remaining_args, ctx).await; + } + #[cfg(feature = "steer")] + if subcommand == "steer" { + return CommandResult::Error( + "Use /thinking steer while an interactive turn is running.".to_string(), + ); } // Extended thinking is configured through the model; just inform the user let model = ctx.config.effective_model(); @@ -5281,8 +5293,7 @@ impl SlashCommand for ExportCommand { async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { // ── Parse flags ──────────────────────────────────────────────────── let args = args.trim(); - // Subcommands absorbed from the former /copy and /share commands - // (issue #73); both stay as hidden one-release aliases. + // /export owns the retained copy and share implementations. let (head, rest) = match args.split_once(char::is_whitespace) { Some((h, r)) => (h, r.trim()), None => (args, ""), @@ -5940,11 +5951,11 @@ impl SlashCommand for EffortCommand { } async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { - // /effort fast absorbs the former standalone /fast command - // (issue #73); the old name stays a hidden one-release alias. + // /effort fast owns the retained fast-mode implementation. let trimmed = args.trim(); - if let Some(rest) = trimmed.strip_prefix("fast") { - return FastCommand.execute(rest.trim(), ctx).await; + let (subcommand, rest) = grouped_commands::split_subcommand(trimmed); + if subcommand == "fast" { + return FastCommand.execute(rest, ctx).await; } match args.trim() { "" => CommandResult::Message( @@ -6674,6 +6685,9 @@ impl SlashCommand for ChromeCommand { fn name(&self) -> &str { "chrome" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Browser automation via Chrome DevTools Protocol (CDP)" } @@ -7107,12 +7121,21 @@ impl SlashCommand for UpgradeCommand { "Check for updates and download the latest release" } fn help(&self) -> &str { - "Usage: /update\n\n\ + "Usage: /update [version|notes]\n\n\ Checks GitHub releases for the latest version of Coven.\n\ - If a newer version is available, shows where to download it." + If a newer version is available, shows where to download it.\n\ + Use /update version to show the installed version or /update notes for highlights." } - async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + let (subcommand, remaining_args) = grouped_commands::split_subcommand(args); + match subcommand { + "version" => return VersionCommand.execute(remaining_args, ctx).await, + "notes" | "release-notes" => { + return ReleaseNotesCommand.execute(remaining_args, ctx).await + } + _ => {} + } let current = claurst_core::constants::APP_VERSION; match fetch_latest_release(current).await { @@ -7810,6 +7833,9 @@ impl SlashCommand for BtwCommand { fn name(&self) -> &str { "whisper" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["btw"] } @@ -7850,6 +7876,9 @@ impl SlashCommand for SandboxToggleCommand { fn name(&self) -> &str { "sandbox" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["sandbox-toggle"] } @@ -8451,6 +8480,9 @@ impl SlashCommand for ProvidersCommand { fn name(&self) -> &str { "providers" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "List available AI providers and their status" } @@ -8517,6 +8549,9 @@ impl SlashCommand for ConnectCommand { fn name(&self) -> &str { "connect" } + fn hidden(&self) -> bool { + true + } fn description(&self) -> &str { "Connect an AI provider" } @@ -8537,7 +8572,7 @@ impl SlashCommand for ConnectCommand { // agent map — `default_agents_with_familiars_and_config` — so they share a // single command. `/familiar` absorbs the former `/agent` (which had // absorbed `/agents` and `/managed-agents`, issue #73); `agent` stays as an -// alias and `/agents` / `/managed-agents` remain hidden one-release shims. +// ergonomic alias while the removed slash roots point users here. // // Switching updates settings.json AND live-swaps app.config so the mascot // changes immediately; the TUI main loop also activates the familiar's @@ -8800,11 +8835,51 @@ fn familiar_rename(rest: &str) -> CommandResult { } } +#[async_trait] +impl SlashCommand for HandoffCommand { + fn name(&self) -> &str { + "handoff" + } + + fn hidden(&self) -> bool { + true + } + + fn description(&self) -> &str { + "Hand work to a familiar in the TUI" + } + + async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult { + CommandResult::Silent + } +} + +#[cfg(feature = "steer")] +#[async_trait] +impl SlashCommand for SteerCommand { + fn name(&self) -> &str { + "steer" + } + + fn hidden(&self) -> bool { + true + } + + fn description(&self) -> &str { + "Queue guidance for the running turn in the TUI" + } + + async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult { + CommandResult::Silent + } +} + #[async_trait] impl SlashCommand for FamiliarCommand { fn name(&self) -> &str { "familiar" } + fn description(&self) -> &str { "Your familiar & agents — switch persona, inspect, and manage" } @@ -8812,13 +8887,16 @@ impl SlashCommand for FamiliarCommand { vec!["familiars", "agent"] } fn help(&self) -> &str { - "Usage: /familiar [name | info | list|create|edit|delete [name] | managed ... | clear | wipe-roster | auto]\n\n\ + "Usage: /familiar [name | incant ... | whisper | handoff | info | list|create|edit|delete [name] | managed ... | clear | wipe-roster | auto]\n\n\ One surface for familiars and agents — both resolve through the same\n\ runtime agent map and access-tier security under the hood.\n\n\ /familiar Current familiar, roster, and agents (with access tiers)\n\ /familiar Switch familiar — persists and applies the persona's\n\ agent definition + tool access immediately\n\ /familiar Details for a built-in or workspace agent\n\ + /familiar incant ... Change the model's speech incantation\n\ + /familiar whisper Ask a side question without history\n\ + /familiar handoff Hand the current work to a familiar in the TUI\n\ /familiar info Details for any familiar or agent\n\ /familiar list|create|edit|delete [name] Manage workspace agents\n\ /familiar new [name] Create a familiar (standalone; writes ~/.coven/familiars.toml)\n\ @@ -8835,12 +8913,12 @@ impl SlashCommand for FamiliarCommand { async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { let trimmed = args.trim(); - let (head, rest) = match trimmed.split_once(char::is_whitespace) { - Some((h, r)) => (h, r.trim()), - None => (trimmed, ""), - }; + let (head, rest) = grouped_commands::split_subcommand(trimmed); match head { + "incant" => return IncantCommand.execute(rest, ctx).await, + "whisper" => return BtwCommand.execute(rest, ctx).await, + "handoff" => return CommandResult::Silent, // Workspace agent management (absorbed from /agents via /agent). "list" | "create" | "edit" | "delete" => { return execute_named_command_from_slash("agents", trimmed, ctx); @@ -10007,8 +10085,7 @@ impl SlashCommand for CovenCommand { let sub = parts.next().unwrap_or("").trim(); let rest = parts.next().unwrap_or("").trim(); - // /coven goal absorbs the former standalone /goal command - // (issue #73); /goal stays as a hidden one-release alias. + // /coven goal owns the retained durable-goal implementation. if sub == "goal" { return GoalCommand.execute(rest, ctx).await; } @@ -10604,6 +10681,9 @@ impl SlashCommand for LinkCommand { fn name(&self) -> &str { "link" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["links"] } @@ -10728,6 +10808,9 @@ impl SlashCommand for AttachCommand { fn name(&self) -> &str { "attach" } + fn hidden(&self) -> bool { + true + } fn aliases(&self) -> Vec<&str> { vec!["attachments"] } @@ -10886,7 +10969,8 @@ impl SlashCommand for AttachCommand { } static COMMANDS: Lazy>> = Lazy::new(|| { - vec![ + let mut commands: Vec> = vec![ + Box::new(AccountCommand), Box::new(HelpCommand), Box::new(ClearCommand), Box::new(CompactCommand), @@ -10897,111 +10981,54 @@ static COMMANDS: Lazy>> = Lazy::new(|| { Box::new(VersionCommand), Box::new(ReleaseNotesCommand), Box::new(ResumeCommand), - Box::new(ReloadPluginsCommand), Box::new(StatusCommand), Box::new(DiffCommand), Box::new(MemoryCommand), Box::new(BugCommand), Box::new(UsageCommand), - Box::new(DoctorCommand), Box::new(LoginCommand), Box::new(LogoutCommand), Box::new(AccountsCommand), - Box::new(SwitchCommand), - Box::new(RefreshCommand), Box::new(IncantCommand), Box::new(InitCommand), Box::new(LearnCommand), Box::new(ReviewCommand), Box::new(HooksCommand), - Box::new(ImportConfigCommand), Box::new(McpCommand), Box::new(PermissionsCommand), Box::new(PlanCommand), Box::new(TasksCommand), Box::new(SessionCommand), - Box::new(ForkCommand), Box::new(ThinkingCommand), - Box::new(ThemeCommand), - Box::new(OutputStyleCommand), - Box::new(KeybindingsCommand), Box::new(SplashCommand), - // New commands Box::new(ExportCommand), - Box::new(ShareCommand), Box::new(SkillsCommand), Box::new(RewindCommand), Box::new(EffortCommand), Box::new(CommitCommand), - Box::new(NamedCommandAdapter { - slash_name: "add-dir", - target_name: "add-dir", - slash_aliases: &[], - slash_description: "Add a directory to Coven's allowed workspace paths", - slash_help: "Usage: /add-dir ", - slash_hidden: true, - }), - Box::new(NamedCommandAdapter { - slash_name: "agents", - target_name: "agents", - slash_aliases: &[], - slash_description: "Manage and configure sub-agents", - slash_help: "Usage: /agents [list|create|edit|delete|reset] [name]\nDeprecated: use /familiar [list|create|edit|delete|reset-roster|managed]", - slash_hidden: true, - }), - Box::new(NamedCommandAdapter { - slash_name: "branch", - target_name: "branch", - slash_aliases: &[], - slash_description: "Create a branch of the current conversation at this point", - slash_help: "Usage: /branch [create|switch|list] [name]", - slash_hidden: true, - }), - Box::new(NamedCommandAdapter { - slash_name: "tag", - target_name: "tag", - slash_aliases: &[], - slash_description: "Toggle a searchable tag on the current session", - slash_help: "Usage: /tag [list|add|remove] [tag]", - slash_hidden: true, - }), - Box::new(NamedCommandAdapter { - slash_name: "pr-comments", - target_name: "pr-comments", - slash_aliases: &[], - slash_description: "Get comments from a GitHub pull request", - slash_help: "Usage: /pr-comments ", - slash_hidden: true, - }), - // Batch-1 new commands - Box::new(CopyCommand), Box::new(ChromeCommand), Box::new(UpgradeCommand), - Box::new(FastCommand), - Box::new(ThinkBackCommand), - // /whisper (BtwCommand) and /sandbox (SandboxToggleCommand) Box::new(BtwCommand), Box::new(SandboxToggleCommand), - // Advisor - Box::new(AdvisorCommand), - // Snapshot / revert system - // Multi-provider support Box::new(ProvidersCommand), Box::new(ConnectCommand), - // Unified familiar & agent surface (/familiar, alias /agent) Box::new(FamiliarCommand), - // Session search (SQLite) + Box::new(HandoffCommand), + #[cfg(feature = "steer")] + Box::new(SteerCommand), Box::new(SearchCommand), - // Managed agent (manager-executor) architecture - Box::new(ManagedAgentsCommand), - // Durable long-running goals - Box::new(GoalCommand), - // Structured link/attachment stash Box::new(LinkCommand), Box::new(AttachCommand), - // Coven daemon control surface (sessions, harness runs, rituals) Box::new(CovenCommand), - ] + Box::new(IntegrationsCommand), + Box::new(StashCommand), + ]; + let catalog = catalog::builtin_catalog_template(); + commands.retain(|command| { + !catalog::find_catalog_spec(&catalog, command.name()) + .is_some_and(|spec| matches!(spec.lifecycle, catalog::CommandLifecycle::Removed { .. })) + }); + commands }); /// Return all built-in slash commands. @@ -11018,21 +11045,214 @@ pub fn find_command(name: &str) -> Option<&'static dyn SlashCommand> { .map(|c| c.as_ref()) } -/// Build `HelpEntry` values for all non-hidden commands, suitable for -/// populating `HelpOverlay::commands` at startup. -pub fn build_help_entries() -> Vec { - all_commands() - .iter() - .filter(|c| !c.hidden()) - .map(|c| claurst_tui::overlays::HelpEntry { - name: c.name().to_string(), - aliases: c.aliases().join(", "), - description: c.description().to_string(), - category: command_category(c.name()).to_string(), +/// Return built-in catalog metadata with registered primary command summaries. +pub fn builtin_catalog() -> Vec { + let mut catalog = catalog::builtin_catalog_template(); + + for spec in &mut catalog { + if let Some(command) = all_commands() + .iter() + .find(|command| command.name() == spec.path) + { + spec.summary = SlashCommand::description(command.as_ref()).to_string(); + } + } + + catalog +} + +impl From for claurst_tui::CommandPaletteEntry { + fn from(spec: catalog::CommandSpec) -> Self { + let source = match spec.source { + catalog::CommandSource::BuiltIn => "built_in", + catalog::CommandSource::User => "user", + catalog::CommandSource::Skill => "skill", + catalog::CommandSource::Plugin => "plugin", + }; + Self { + path: spec.path, + aliases: spec.aliases, + summary: spec.summary, + usage: spec.usage, + category: spec.category.label().to_string(), + keywords: spec.keywords, + source: source.to_string(), + suggest_when: spec.suggest_when, + children: spec.children.into_iter().map(Self::from).collect(), + } + } +} + +fn dynamic_catalog_entry( + path: String, + summary: String, + source: catalog::CommandSource, +) -> catalog::CommandSpec { + catalog::CommandSpec::active( + &path, + &summary, + &format!("/{path} [arguments]"), + catalog::CommandCategory::Extend, + &[&path], + ) + .with_source(source) +} + +fn sort_catalog_for_presentation(catalog: &mut [catalog::CommandSpec]) { + for spec in catalog.iter_mut() { + sort_catalog_for_presentation(&mut spec.children); + } + catalog.sort_by(|left, right| { + left.category + .rank() + .cmp(&right.category.rank()) + .then(left.path.cmp(&right.path)) + }); +} + +fn retain_presentable_children(spec: &mut catalog::CommandSpec) { + spec.children.retain(|child| { + matches!( + &child.lifecycle, + catalog::CommandLifecycle::Active | catalog::CommandLifecycle::Advanced + ) + }); + for child in &mut spec.children { + retain_presentable_children(child); + } +} + +fn visible_builtin_presentation_catalog() -> Vec { + builtin_catalog() + .into_iter() + .filter(|spec| { + matches!( + &spec.lifecycle, + catalog::CommandLifecycle::Active | catalog::CommandLifecycle::Advanced + ) + }) + .filter_map(|mut spec| { + match find_command(&spec.path) { + Some(command) if command.hidden() => return None, + None if !TUI_ONLY_RESERVED_IDENTIFIERS.contains(&spec.path.as_str()) => { + return None + } + _ => {} + } + retain_presentable_children(&mut spec); + Some(spec) + }) + .collect() +} + +const TUI_ONLY_RESERVED_IDENTIFIERS: &[&str] = &[ + "changes", "context", "cost", "find", "rename", "stats", "vim", "voice", +]; + +fn builtin_reserved_identifiers() -> BTreeSet { + catalog::flatten_catalog(&builtin_catalog()) + .into_iter() + .flat_map(|spec| { + std::iter::once(spec.path.as_str()).chain(spec.aliases.iter().map(String::as_str)) + }) + .chain( + all_commands() + .iter() + .flat_map(|command| std::iter::once(command.name()).chain(command.aliases())), + ) + .chain(TUI_ONLY_RESERVED_IDENTIFIERS.iter().copied()) + .map(str::to_string) + .collect() +} + +fn filter_dynamic_builtin_collisions( + dynamic_entries: Vec, +) -> Vec { + let builtin_identifiers = builtin_reserved_identifiers(); + + dynamic_entries + .into_iter() + .filter(|spec| { + spec.flattened().into_iter().all(|entry| { + std::iter::once(entry.path.as_str()) + .chain(entry.aliases.iter().map(String::as_str)) + .all(|identifier| !builtin_identifiers.contains(identifier)) + }) }) .collect() } +/// Build the catalog presented by the current TUI command discovery surfaces. +pub fn composed_catalog( + config: &claurst_core::config::Config, + cwd: &std::path::Path, + plugin_command_defs: &[claurst_plugins::PluginCommandDef], +) -> Vec { + let user_templates = filter_dynamic_builtin_collisions( + config + .commands + .iter() + .map(|(name, template)| { + dynamic_catalog_entry( + name.clone(), + template + .description + .clone() + .unwrap_or_else(|| "Custom command".to_string()), + catalog::CommandSource::User, + ) + }) + .collect(), + ); + + let mut discovered_skills = claurst_core::discover_skills(cwd, &config.skills) + .into_values() + .collect::>(); + discovered_skills.sort_by(|left, right| left.name.cmp(&right.name)); + let discovered_skills = filter_dynamic_builtin_collisions( + discovered_skills + .into_iter() + .map(|skill| { + dynamic_catalog_entry( + skill.name, + if skill.description.is_empty() { + "Discovered skill".to_string() + } else { + skill.description + }, + catalog::CommandSource::Skill, + ) + }) + .collect(), + ); + + let plugin_commands = filter_dynamic_builtin_collisions( + plugin_command_defs + .iter() + .map(|command| { + dynamic_catalog_entry( + command.name.clone(), + command.description.clone(), + catalog::CommandSource::Plugin, + ) + }) + .collect(), + ); + + let mut catalog = catalog::compose_catalogs( + visible_builtin_presentation_catalog(), + user_templates, + discovered_skills, + plugin_commands, + ); + sort_catalog_for_presentation(&mut catalog); + + catalog + .into_iter() + .map(claurst_tui::CommandPaletteEntry::from) + .collect() +} + // --------------------------------------------------------------------------- // User-defined command templates (Feature 2) // --------------------------------------------------------------------------- @@ -11145,6 +11365,85 @@ pub fn commands_from_discovered_skills( .collect() } +struct CatalogCommandResolution { + path: String, + input_path: String, + lifecycle: catalog::CommandLifecycle, + dispatch_name: String, + dispatch_args: String, +} + +fn resolve_catalog_command(name: &str, args: &str) -> Option { + let name = name.trim_start_matches('/').trim().to_ascii_lowercase(); + let catalog = catalog::builtin_catalog_template(); + let (child, remaining_args) = grouped_commands::split_subcommand(args); + let child = child.to_ascii_lowercase(); + let child_input = (!child.is_empty()).then(|| format!("{name} {child}")); + let child_spec = child_input + .as_deref() + .and_then(|path| catalog::find_catalog_spec(&catalog, path)) + .filter(|spec| spec.path.contains(' ')); + let direct_spec = catalog::find_catalog_spec(&catalog, &name); + let (spec, matched_child_input) = child_spec + .map(|spec| (spec, true)) + .or_else(|| direct_spec.map(|spec| (spec, false)))?; + + let (dispatch_name, dispatch_args) = match spec.path.split_once(' ') { + Some((parent, canonical_child)) => { + let child_args = if matched_child_input { + remaining_args + } else { + args.trim() + }; + let dispatch_args = if child_args.is_empty() { + canonical_child.to_string() + } else { + format!("{canonical_child} {child_args}") + }; + (parent.to_string(), dispatch_args) + } + None => (name.to_string(), args.trim().to_string()), + }; + + Some(CatalogCommandResolution { + path: spec.path.clone(), + input_path: if matched_child_input { + child_input.unwrap_or_else(|| name.to_string()) + } else { + name.to_string() + }, + lifecycle: spec.lifecycle.clone(), + dispatch_name, + dispatch_args, + }) +} + +/// Resolve a built-in catalog path to its canonical path. +pub fn canonical_catalog_path(name: &str, args: &str) -> Option { + resolve_catalog_command(name, args).map(|resolution| resolution.path) +} + +/// Validate a parsed interactive slash command before a presentation layer +/// intercepts it. This prevents removed paths from opening or mutating TUI +/// surfaces. Removed paths never dispatch to a retained implementation. +pub fn preflight_interactive_slash_command( + name: &str, + args: &str, + _ctx: &mut CommandContext, +) -> Result<(), String> { + let Some(resolution) = resolve_catalog_command(name, args) else { + return Ok(()); + }; + + match &resolution.lifecycle { + catalog::CommandLifecycle::Removed { replacement } => Err(format!( + "/{} was removed; use /{}", + resolution.input_path, replacement + )), + catalog::CommandLifecycle::Active | catalog::CommandLifecycle::Advanced => Ok(()), + } +} + /// Execute a slash command string (with leading /). pub async fn execute_command(input: &str, ctx: &mut CommandContext) -> Option { if !claurst_tui::input::is_slash_command(input) { @@ -11152,13 +11451,32 @@ pub async fn execute_command(input: &str, ctx: &mut CommandContext) -> Option Option` subcommands) // --------------------------------------------------------------------------- +pub mod catalog; +mod grouped_commands; pub mod named_commands; +pub use grouped_commands::{AccountCommand, IntegrationsCommand, StashCommand}; // --------------------------------------------------------------------------- // Stats analytics (persisted transcript aggregation) — backs `coven-code stats`. @@ -11327,10 +11648,243 @@ mod tests { assert!(!all_commands().is_empty()); } - // ---- Stash command tests ------------------------------------------------- - #[test] - fn parse_stash_args_splits_positionals_and_flags() { + fn grouped_command_roots_are_registered() { + for name in ["account", "integrations", "stash"] { + assert!( + find_command(name).is_some(), + "/{name} should be registered as a canonical grouped command" + ); + } + } + + #[test] + fn non_hidden_registered_primaries_match_the_canonical_command_set() { + let actual = all_commands() + .iter() + .filter(|command| !command.hidden()) + .map(|command| command.name()) + .collect::>(); + let expected = [ + "account", + "clear", + "commit", + "compact", + "config", + "coven", + "diff", + "effort", + "exit", + "export", + "familiar", + "help", + "init", + "integrations", + "learn", + "memory", + "model", + "permissions", + "plan", + "plugin", + "review", + "rewind", + "search", + "session", + "skills", + "stash", + "status", + "tasks", + "thinking", + "update", + "usage", + ] + .into_iter() + .collect::>(); + + assert_eq!(actual.len(), 31); + assert_eq!(actual, expected); + } + + #[test] + fn active_catalog_child_aliases_dispatch_through_their_parent() { + for (name, args) in [ + ("reload", ""), + ("plugin", "refresh"), + ("plugins", "reload"), + ("plugins", "refresh"), + ] { + let resolution = resolve_catalog_command(name, args) + .unwrap_or_else(|| panic!("/{name} {args} should resolve")); + assert_eq!(resolution.path, "plugin reload"); + assert_eq!(resolution.dispatch_name, "plugin"); + assert_eq!(resolution.dispatch_args, "reload"); + assert!(matches!( + resolution.lifecycle, + catalog::CommandLifecycle::Active + )); + } + } + + #[tokio::test] + async fn grouped_parents_delegate_and_explain_unknown_children() { + let _guard = CommandEnvGuard::with_coven_home(None); + let mut ctx = make_ctx(); + + assert!(matches!( + execute_command("/account login --codex", &mut ctx).await, + Some(CommandResult::StartLoginForProvider { provider, .. }) + if provider == claurst_core::accounts::PROVIDER_CODEX + )); + assert!(matches!( + execute_command("/account refresh", &mut ctx).await, + Some(CommandResult::RefreshProviderState) + )); + assert!(matches!( + execute_command("/account connect", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("connect dialog") + )); + assert!(matches!( + execute_command("/integrations hooks", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("No hooks configured") + )); + assert!(matches!( + execute_command("/integrations chrome", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("Usage: /chrome") + )); + assert!(matches!( + execute_command("/stash link not-a-url", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("Usage:") + )); + + for (input, children) in [ + ("/account mystery", "login, logout, list, dedupe"), + ("/integrations mystery", "hooks, mcp, chrome"), + ("/stash mystery", "link, links, attach, attachments"), + ] { + let result = execute_command(input, &mut ctx).await; + let Some(CommandResult::Error(message)) = result else { + panic!("{input} should reject an unknown child"); + }; + assert!(message.contains("Valid children:"), "{message}"); + assert!(message.contains(children), "{message}"); + assert!(message.contains("Usage: /"), "{message}"); + } + } + + #[tokio::test] + async fn canonical_parent_subcommands_delegate_to_existing_handlers() { + let _guard = CommandEnvGuard::with_coven_home(None); + let mut ctx = make_ctx(); + + assert!(matches!( + execute_command("/config splash show", &mut ctx).await, + Some(CommandResult::ConfigChangeMessage(_, _)) + )); + assert!(matches!( + execute_command("/permissions sandbox status", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("Sandbox mode") + )); + assert!(matches!( + execute_command("/familiar incant caveman", &mut ctx).await, + Some(CommandResult::SpeechMode { .. }) + )); + assert!(matches!( + execute_command("/familiar whisper explain this", &mut ctx).await, + Some(CommandResult::UserMessage(message)) if message.contains("explain this") + )); + assert!(matches!( + execute_command("/update version", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("Coven v") + )); + assert!(matches!( + execute_command("/update notes", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("What's new") + )); + assert!(matches!( + execute_command("/thinking back", &mut ctx).await, + Some(CommandResult::Message(_)) + )); + + let session_result = execute_command("/session resume", &mut ctx).await; + assert!( + !matches!( + session_result, + Some(CommandResult::Error(message)) if message.contains("Unknown subcommand") + ), + "/session resume should delegate to ResumeCommand" + ); + } + + #[tokio::test] + async fn removed_paths_return_exact_replacements_before_dynamic_templates() { + let mut ctx = make_ctx(); + let removed = [ + ("reload-plugins", "plugin reload"), + ("doctor", "status doctor"), + ("switch", "account switch"), + ("refresh", "account refresh"), + ("import-config", "config import"), + ("fork", "session fork"), + ("theme", "config theme"), + ("output-style", "config output-style"), + ("keybindings", "config keybindings"), + ("share", "export share"), + ("add-dir", "session add-dir"), + ("agents", "familiar list"), + ("branch", "session branch"), + ("tag", "session tag"), + ("pr-comments", "review pr-comments"), + ("copy", "export copy"), + ("fast", "effort fast"), + ("think-back", "thinking back"), + ("advisor", "config advisor"), + ("managed-agents", "familiar managed"), + ("goal", "coven goal"), + ]; + + for (name, replacement) in removed { + assert_eq!( + preflight_interactive_slash_command(name, "", &mut ctx), + Err(format!("/{name} was removed; use /{replacement}")), + "/{name} did not return its exact interactive preflight error" + ); + ctx.config.commands.insert( + name.to_string(), + claurst_core::CommandTemplate { + template: "dynamic command must not run".to_string(), + ..Default::default() + }, + ); + let result = execute_command(&format!("/{name}"), &mut ctx).await; + assert!( + matches!( + result, + Some(CommandResult::Error(message)) + if message == format!("/{name} was removed; use /{replacement}") + ), + "/{name} did not return its exact removal error" + ); + } + } + + #[test] + fn deprecated_accounts_dedupe_has_specific_replacement() { + let resolution = + resolve_catalog_command("accounts", "dedupe").expect("accounts dedupe should resolve"); + assert_eq!(resolution.path, "accounts dedupe"); + assert_eq!(resolution.dispatch_name, "accounts"); + assert_eq!(resolution.dispatch_args, "dedupe"); + assert!(matches!( + resolution.lifecycle, + catalog::CommandLifecycle::Removed { ref replacement, .. } + if replacement == "account dedupe" + )); + } + + // ---- Stash command tests ------------------------------------------------- + + #[test] + fn parse_stash_args_splits_positionals_and_flags() { let (positional, flags) = parse_stash_args("add https://example.com --title Example site --tags a,b --note x y"); assert_eq!(positional, vec!["add", "https://example.com"]); @@ -11503,6 +12057,305 @@ mod tests { } } + #[test] + fn active_presentation_paths_are_unique() { + let mut paths = std::collections::HashSet::new(); + for spec in catalog::flatten_catalog(&visible_builtin_presentation_catalog()) { + assert!( + paths.insert(spec.path.as_str()), + "duplicate presentation path" + ); + } + } + + #[test] + fn catalog_presentation_includes_all_non_hidden_registered_primaries() { + let catalog = visible_builtin_presentation_catalog(); + let paths = catalog::flatten_catalog(&catalog) + .into_iter() + .map(|spec| spec.path.as_str()) + .collect::>(); + let registered_primaries = all_commands() + .iter() + .filter(|command| !command.hidden()) + .map(|command| command.name()) + .collect::>(); + + assert!( + registered_primaries.is_subset(&paths), + "non-hidden registered commands missing from presentation: {:?}", + registered_primaries.difference(&paths).collect::>() + ); + for path in ["account", "integrations", "stash"] { + assert!( + paths.contains(path), + "canonical grouped root /{path} is missing" + ); + } + for path in ["login", "accounts", "mcp", "attach", "resume"] { + assert!( + !paths.contains(path), + "deprecated root /{path} must not be presented" + ); + } + } + + #[test] + fn catalog_presentation_includes_all_active_folded_children() { + let catalog = visible_builtin_presentation_catalog(); + let paths = catalog::flatten_catalog(&catalog) + .into_iter() + .map(|spec| spec.path.as_str()) + .collect::>(); + + for path in [ + "account login", + "account logout", + "account list", + "account dedupe", + "account providers", + "account connect", + "account switch", + "account refresh", + "config theme", + "config output-style", + "config splash", + "usage cost", + "usage context", + "usage stats", + "session rename", + "session resume", + "session fork", + "session branch", + "session tag", + "session add-dir", + "review pr-comments", + "review security", + "review ultra", + "rewind list", + "rewind diff", + "thinking back", + "effort fast", + "export copy", + "export share", + "status doctor", + "plugin reload", + "integrations hooks", + "integrations mcp", + "integrations chrome", + "stash link", + "stash attach", + "permissions sandbox", + "update version", + "update notes", + "familiar incant", + "familiar whisper", + "familiar handoff", + ] { + assert!(paths.contains(path), "missing visible child path {path}"); + } + #[cfg(feature = "steer")] + assert!(paths.contains("thinking steer")); + } + + #[test] + fn active_catalog_children_have_a_command_or_tui_route() { + let command_routes = [ + "help feedback", + "account login", + "account logout", + "account list", + "account dedupe", + "account providers", + "account switch", + "account refresh", + "config splash", + "config theme", + "config output-style", + "config keybindings", + "config import", + "config advisor", + "coven goal", + "effort fast", + "export share", + "export copy", + "familiar incant", + "familiar whisper", + "familiar list", + "familiar managed", + "integrations chrome", + "permissions sandbox", + "plugin reload", + "review pr-comments", + "review security", + "review ultra", + "rewind list", + "rewind diff", + "session rename", + "session resume", + "session fork", + "session branch", + "session tag", + "session add-dir", + "stash link", + "stash attach", + "status doctor", + "thinking back", + "update version", + "update notes", + "usage cost", + "usage context", + "usage stats", + ] + .into_iter() + .collect::>(); + let tui_routes = [ + "account connect", + "familiar handoff", + "integrations hooks", + "integrations mcp", + ] + .into_iter() + .collect::>(); + #[cfg(feature = "steer")] + let tui_routes = { + let mut routes = tui_routes; + routes.insert("thinking steer"); + routes + }; + + for spec in catalog::flatten_catalog(&catalog::builtin_catalog_template()) { + if spec.path.contains(' ') + && matches!( + &spec.lifecycle, + catalog::CommandLifecycle::Active | catalog::CommandLifecycle::Advanced + ) + { + assert!( + command_routes.contains(spec.path.as_str()) + || tui_routes.contains(spec.path.as_str()), + "{} has no command or TUI route", + spec.path + ); + } + } + } + + #[test] + fn dynamic_catalog_reserves_registered_catalog_and_tui_builtin_surfaces() { + assert!(find_command("speed").is_none()); + assert!(matches!( + catalog::find_catalog_spec(&builtin_catalog(), "login") + .expect("login catalog metadata") + .lifecycle, + catalog::CommandLifecycle::Removed { .. } + )); + + let entries = filter_dynamic_builtin_collisions(vec![ + dynamic_catalog_entry( + "login".to_string(), + "Custom login".to_string(), + catalog::CommandSource::User, + ), + dynamic_catalog_entry( + "speed".to_string(), + "Custom speed".to_string(), + catalog::CommandSource::Skill, + ), + dynamic_catalog_entry( + "survey".to_string(), + "Custom survey".to_string(), + catalog::CommandSource::Plugin, + ), + dynamic_catalog_entry( + "handoff".to_string(), + "Custom handoff".to_string(), + catalog::CommandSource::User, + ), + dynamic_catalog_entry( + "stats".to_string(), + "Custom stats".to_string(), + catalog::CommandSource::Plugin, + ), + ]); + + assert!(entries.is_empty()); + } + + #[test] + fn composed_catalog_includes_passed_plugin_command_definitions() { + let plugin_commands = vec![claurst_plugins::PluginCommandDef { + name: "catalog-plugin-fixture".to_string(), + description: "Plugin catalog fixture".to_string(), + plugin_name: "fixture".to_string(), + plugin_source_id: "fixture-source".to_string(), + run_action: claurst_plugins::CommandRunAction::StaticResponse(String::new()), + plugin_capabilities: None, + }]; + + let catalog = composed_catalog( + &claurst_core::config::Config::default(), + std::path::Path::new("."), + &plugin_commands, + ); + + assert!(catalog + .iter() + .any(|entry| { entry.path == "catalog-plugin-fixture" && entry.source == "plugin" })); + } + + #[test] + fn composed_catalog_uses_canonical_categories_and_path_order() { + let plugin_commands = vec![claurst_plugins::PluginCommandDef { + name: "catalog-plugin-fixture".to_string(), + description: "Plugin catalog fixture".to_string(), + plugin_name: "fixture".to_string(), + plugin_source_id: "fixture-source".to_string(), + run_action: claurst_plugins::CommandRunAction::StaticResponse(String::new()), + plugin_capabilities: None, + }]; + let catalog = composed_catalog( + &claurst_core::config::Config::default(), + std::path::Path::new("."), + &plugin_commands, + ); + let mut sections = Vec::new(); + + for entry in &catalog { + if sections.last() != Some(&entry.category) { + sections.push(entry.category.clone()); + } + } + + assert_eq!( + sections, + vec![ + "Start & Navigate", + "Work & Review", + "Conversation", + "Account & Models", + "Customize", + "Extend", + "Coven & Advanced", + ] + ); + assert!(catalog + .iter() + .find(|entry| entry.path == "catalog-plugin-fixture") + .is_some_and(|entry| entry.category == "Extend" && entry.source == "plugin")); + for entries in catalog.windows(2) { + if entries[0].category == entries[1].category { + assert!( + entries[0].path <= entries[1].path, + "{} should precede {} in {}", + entries[0].path, + entries[1].path, + entries[0].category, + ); + } + } + } + #[test] fn structured_review_output_parses_memory_refs() { let review = parse_structured_review_output( @@ -11629,8 +12482,8 @@ mod tests { assert!(find_command("clear").is_some()); assert!(find_command("exit").is_some()); assert!(find_command("model").is_some()); - assert!(find_command("refresh").is_some()); - assert!(find_command("version").is_some()); + assert!(find_command("account").is_some()); + assert!(find_command("update").is_some()); } #[tokio::test] @@ -11639,8 +12492,8 @@ mod tests { let mut ctx = make_ctx(); ctx.config.show_splash = Some(true); - let command = find_command("splash").expect("/splash command"); - let result = command.execute("hide", &mut ctx).await; + let command = find_command("config").expect("/config command"); + let result = command.execute("splash hide", &mut ctx).await; match result { CommandResult::ConfigChangeMessage(config, message) => { @@ -11660,8 +12513,8 @@ mod tests { let mut ctx = make_ctx(); ctx.config.show_splash = Some(true); - let command = find_command("splash").expect("/splash command"); - let result = command.execute("", &mut ctx).await; + let command = find_command("config").expect("/config command"); + let result = command.execute("splash ", &mut ctx).await; match result { CommandResult::ConfigChangeMessage(config, message) => { @@ -11704,39 +12557,74 @@ mod tests { } #[test] - fn phase_three_legacy_commands_are_hidden_but_callable() { - let hidden_legacy = [ - "agents", - "managed-agents", + fn expired_roots_are_absent_from_the_registry() { + let expired = [ + "reload-plugins", + "doctor", + "switch", + "refresh", + "import-config", "fork", + "theme", + "output-style", + "keybindings", + "share", + "add-dir", + "agents", "branch", "tag", - "add-dir", + "pr-comments", "copy", - "share", - "switch", - "refresh", - "think-back", "fast", - "keybindings", - "theme", - "output-style", - "import-config", + "think-back", "advisor", - "doctor", - "reload-plugins", - "pr-comments", + "managed-agents", + "goal", ]; - for name in hidden_legacy { - let command = find_command(name).unwrap_or_else(|| panic!("{name} should resolve")); + assert_eq!(expired.len(), 21); + for name in expired { assert!( - command.hidden(), - "{name} should stay callable as a hidden one-release compatibility alias" + find_command(name).is_none(), + "{name} should be absent after its compatibility window" ); } } + #[test] + fn retired_roots_are_not_registered() { + for name in [ + "login", + "logout", + "accounts", + "providers", + "connect", + "hooks", + "mcp", + "chrome", + "link", + "attach", + "resume", + "splash", + "sandbox", + "incant", + "whisper", + "version", + "release-notes", + "feedback", + "handoff", + ] { + assert!( + find_command(name).is_none(), + "{name} must no longer dispatch" + ); + } + #[cfg(feature = "steer")] + { + assert!(find_command("steer").is_none()); + } + } + #[tokio::test] async fn usage_stats_subcommand_routes_to_stats() { let _guard = CommandEnvGuard::with_coven_home(None); @@ -11781,16 +12669,16 @@ mod tests { } #[tokio::test] - async fn login_thinking_effort_absorb_their_clusters() { + async fn account_thinking_effort_absorb_their_clusters() { let _guard = CommandEnvGuard::with_coven_home(None); let mut ctx = make_ctx(); - // /login refresh routes to the provider-reset path. - let login = find_command("login").unwrap(); + // /account refresh routes to the provider-reset path. + let login = find_command("account").unwrap(); let result = login.execute("refresh", &mut ctx).await; assert!( matches!(result, CommandResult::RefreshProviderState), - "/login refresh should route to the refresh path" + "/account refresh should route to the refresh path" ); // /thinking back routes to the think-back trace viewer. @@ -11808,6 +12696,10 @@ mod tests { matches!(result, CommandResult::ConfigChangeMessage(_, _)), "/effort fast should route to the fast-mode toggle" ); + assert!(matches!( + effort.execute("faston", &mut ctx).await, + CommandResult::Error(_) + )); } #[tokio::test] @@ -11954,12 +12846,12 @@ mod tests { // /clear has alias "c" assert!(find_command("c").is_some()); assert!(find_command("settings").is_some()); - assert!(find_command("continue").is_some()); - assert!(find_command("bug").is_some()); + assert!(find_command("continue").is_none()); + assert!(find_command("bug").is_none()); assert!(find_command("bashes").is_some()); assert!(find_command("remote").is_some()); - assert!(find_command("sandbox-toggle").is_some()); - assert!(find_command("btw").is_some()); + assert!(find_command("sandbox-toggle").is_none()); + assert!(find_command("btw").is_none()); } #[test] @@ -12000,31 +12892,19 @@ mod tests { "exit", "model", "config", - "version", + "account", + "integrations", + "stash", "status", "diff", "memory", - "hooks", "permissions", "plan", "tasks", "session", - "login", - "logout", - "refresh", - "feedback", "usage", + "update", "plugin", - "reload-plugins", - "add-dir", - "agents", - "branch", - "tag", - "pr-comments", - "whisper", - "incant", - "sandbox", - "switch", "review", "coven", "familiar", @@ -12040,6 +12920,54 @@ mod tests { // ---- Command execution tests -------------------------------------------- + #[tokio::test] + async fn help_resolves_grouped_paths_and_preserves_feedback() { + let mut ctx = make_ctx(); + assert!( + matches!(execute_command("/help account login", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("/account login") && message.contains("--codex")) + ); + assert!( + matches!(execute_command("/help feedback usability report", &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains("Suggested report summary: usability report")) + ); + } + + #[tokio::test] + async fn help_preserves_argument_rich_command_details() { + let mut ctx = make_ctx(); + for (path, expected) in [ + ("config", "/config set"), + ("export", "--output"), + ("effort", "low|normal|high"), + ("coven sessions", "sessions"), + ("session fork", "[message_index]"), + ] { + assert!( + matches!(execute_command(&format!("/help {path}"), &mut ctx).await, + Some(CommandResult::Message(message)) if message.contains(expected)), + "{path}" + ); + } + } + + #[tokio::test] + async fn every_retired_path_and_alias_rejects_execution() { + let mut ctx = make_ctx(); + let catalog = builtin_catalog(); + for entry in catalog::flatten_catalog(&catalog) { + if let catalog::CommandLifecycle::Removed { replacement } = &entry.lifecycle { + for path in std::iter::once(&entry.path).chain(entry.aliases.iter()) { + assert!( + matches!(execute_command(&format!("/{path}"), &mut ctx).await, + Some(CommandResult::Error(message)) if message == format!("/{path} was removed; use /{replacement}")), + "retired path /{path} must not execute" + ); + } + } + } + } + #[tokio::test] async fn test_clear_command_returns_clear_conversation() { let mut ctx = make_ctx(); @@ -12049,10 +12977,10 @@ mod tests { } #[tokio::test] - async fn test_refresh_command_requests_provider_reset() { + async fn account_refresh_requests_provider_reset() { let mut ctx = make_ctx(); - let cmd = find_command("refresh").unwrap(); - let result = cmd.execute("", &mut ctx).await; + let cmd = find_command("account").unwrap(); + let result = cmd.execute("refresh", &mut ctx).await; assert!(matches!(result, CommandResult::RefreshProviderState)); } @@ -12071,8 +12999,8 @@ mod tests { // coverage requirement so future doctor refactors don't accidentally // drop the substrate report and silently regress the gap closure. let mut ctx = make_ctx(); - let cmd = find_command("doctor").expect("/doctor must be registered"); - let result = cmd.execute("", &mut ctx).await; + let cmd = find_command("status").expect("/status must be registered"); + let result = cmd.execute("doctor", &mut ctx).await; match result { CommandResult::Message(msg) => { assert!( @@ -12098,7 +13026,7 @@ mod tests { async fn test_doctor_help_mentions_substrate() { // The /doctor help text must document the substrate check so users // can tell what `/doctor` covers without running it. - let cmd = find_command("doctor").expect("/doctor must be registered"); + let cmd = DoctorCommand; assert!( cmd.help().contains("Coven Substrate"), "/doctor help should mention Coven Substrate, got:\n{}", @@ -12109,8 +13037,8 @@ mod tests { #[tokio::test] async fn test_version_command_returns_message() { let mut ctx = make_ctx(); - let cmd = find_command("version").unwrap(); - let result = cmd.execute("", &mut ctx).await; + let cmd = find_command("update").unwrap(); + let result = cmd.execute("version", &mut ctx).await; assert!(matches!(result, CommandResult::Message(_))); if let CommandResult::Message(msg) = result { assert!( @@ -12132,9 +13060,9 @@ mod tests { #[tokio::test] async fn test_login_command_starts_oauth_flow() { let mut ctx = make_ctx(); - let cmd = find_command("login").unwrap(); + let cmd = find_command("account").unwrap(); // Default (no --console) → Anthropic, login_with_claude_ai = true - let result = cmd.execute("", &mut ctx).await; + let result = cmd.execute("login ", &mut ctx).await; match result { CommandResult::StartLoginForProvider { provider, @@ -12152,8 +13080,8 @@ mod tests { #[tokio::test] async fn test_login_command_console_flag() { let mut ctx = make_ctx(); - let cmd = find_command("login").unwrap(); - let result = cmd.execute("--console", &mut ctx).await; + let cmd = find_command("account").unwrap(); + let result = cmd.execute("login --console", &mut ctx).await; match result { CommandResult::StartLoginForProvider { provider, @@ -12170,8 +13098,8 @@ mod tests { #[tokio::test] async fn test_login_command_codex_flag() { let mut ctx = make_ctx(); - let cmd = find_command("login").unwrap(); - let result = cmd.execute("--codex --label work", &mut ctx).await; + let cmd = find_command("account").unwrap(); + let result = cmd.execute("login --codex --label work", &mut ctx).await; match result { CommandResult::StartLoginForProvider { provider, label, .. @@ -12186,8 +13114,8 @@ mod tests { #[tokio::test] async fn test_switch_no_args_lists_accounts() { let mut ctx = make_ctx(); - let cmd = find_command("switch").unwrap(); - let result = cmd.execute("", &mut ctx).await; + let cmd = find_command("account").unwrap(); + let result = cmd.execute("switch", &mut ctx).await; // /switch with no args lists stored accounts (absorbs old /accounts). assert!(matches!(result, CommandResult::Message(_))); } @@ -12337,9 +13265,9 @@ mod tests { #[tokio::test] async fn test_switch_command_requires_id() { let mut ctx = make_ctx(); - let cmd = find_command("switch").unwrap(); + let cmd = find_command("account").unwrap(); // A flag with no profile id is still an error. - let result = cmd.execute("--codex", &mut ctx).await; + let result = cmd.execute("switch --codex", &mut ctx).await; assert!(matches!(result, CommandResult::Error(_))); } @@ -12348,11 +13276,56 @@ mod tests { let mut ctx = make_ctx(); let cmd = find_command("help").unwrap(); let result = cmd.execute("", &mut ctx).await; - // help returns either Message or Silent + let CommandResult::Message(output) = result else { + panic!("help should return grouped text output"); + }; + + let headings = [ + "Start & Navigate", + "Work & Review", + "Conversation", + "Account & Models", + "Customize", + "Extend", + "Coven & Advanced", + ]; + let heading_positions = headings.map(|heading| { + output + .find(&format!("\n{heading}\n")) + .unwrap_or_else(|| panic!("missing help heading {heading}: {output}")) + }); assert!( - matches!(result, CommandResult::Message(_) | CommandResult::Silent), - "help should return Message or Silent" + heading_positions.windows(2).all(|pair| pair[0] < pair[1]), + "help headings are not in stable catalog order: {output}" ); + + for command in ["/account", "/integrations", "/stash"] { + assert!( + output.contains(command), + "help must list canonical grouped command {command}: {output}" + ); + } + + for (command, heading_index) in [ + ("/clear", 0), + ("/commit", 1), + ("/compact", 2), + ("/model", 3), + ("/config", 4), + ("/plugin", 5), + ("/coven", 6), + ] { + let group_start = heading_positions[heading_index]; + let group_end = heading_positions + .get(heading_index + 1) + .copied() + .unwrap_or(output.len()); + assert!( + output[group_start..group_end].contains(command), + "{command} is not listed under {}: {output}", + headings[heading_index] + ); + } } #[tokio::test] @@ -12374,8 +13347,8 @@ mod tests { #[tokio::test] async fn test_import_config_command_opens_overlay() { let mut ctx = make_ctx(); - let cmd = find_command("import-config").unwrap(); - let result = cmd.execute("", &mut ctx).await; + let cmd = find_command("config").unwrap(); + let result = cmd.execute("import", &mut ctx).await; assert!(matches!(result, CommandResult::OpenImportConfigOverlay)); } @@ -12521,73 +13494,6 @@ mod tests { assert!(matches!(result, CommandResult::Error(_))); } - /// The TUI's slash-command autocomplete and `/help` overlay both read - /// from `claurst_tui::app::PROMPT_SLASH_COMMANDS`. That list is hand- - /// maintained — when a new command is added to the registry but not to - /// the list, the TUI silently hides it from autocomplete. This test is - /// the safety net. - #[test] - fn prompt_slash_commands_covers_registry() { - use std::collections::HashSet; - - // Names that appear in autocomplete on purpose even though they're - // not (or not primarily) registered SlashCommands: - // - quit / settings / survey: user-facing aliases of exit / config / - // feedback. - // - handoff: intercepted directly by the TUI via - // `App::intercept_slash_command_with_args` (sends the current - // session context to a Coven familiar). It is documented in - // docs/familiars.md and lives in tui/src/handoff.rs. - // - stats: intercepted directly by the TUI to open the live stats - // dialog; the named CLI `stats` command handles aggregate saved - // session reports outside the TUI. - // - steer: opt-in feature, intercepted directly in the CLI main loop - // (queues/sends a steering message); only present when built with - // `--features steer`. - const ALLOWED_ALIAS_NAMES: &[&str] = &["quit", "settings", "survey", "handoff", "steer"]; - - let prompt_names: HashSet<&str> = claurst_tui::app::PROMPT_SLASH_COMMANDS - .iter() - .map(|(name, _)| *name) - .collect(); - - // Every non-hidden registered command must show up in autocomplete. - let mut missing: Vec<&str> = all_commands() - .iter() - .filter(|c| !c.hidden()) - .map(|c| c.name()) - .filter(|name| !prompt_names.contains(name)) - .collect(); - missing.sort(); - assert!( - missing.is_empty(), - "PROMPT_SLASH_COMMANDS is missing entries for non-hidden \ - registered commands: {missing:?}. Add them to \ - crates/tui/src/app.rs::PROMPT_SLASH_COMMANDS." - ); - - // No orphans in the prompt list (every entry must either match a - // registered command name/alias or be in the allow-list). - let known_names_and_aliases: HashSet<&str> = all_commands() - .iter() - .flat_map(|c| std::iter::once(c.name()).chain(c.aliases())) - .collect(); - let mut orphans: Vec<&str> = prompt_names - .iter() - .copied() - .filter(|name| { - !known_names_and_aliases.contains(name) && !ALLOWED_ALIAS_NAMES.contains(name) - }) - .collect(); - orphans.sort(); - assert!( - orphans.is_empty(), - "PROMPT_SLASH_COMMANDS has orphan entries with no matching \ - command: {orphans:?}. Either register the command or drop \ - the entry." - ); - } - #[tokio::test] async fn test_coven_help_lists_integration_subcommands() { let mut ctx = make_ctx(); diff --git a/src-rust/crates/commands/tests/golden/slash_command_registry.txt b/src-rust/crates/commands/tests/golden/slash_command_registry.txt index 3694411a..af003a19 100644 --- a/src-rust/crates/commands/tests/golden/slash_command_registry.txt +++ b/src-rust/crates/commands/tests/golden/slash_command_registry.txt @@ -1,120 +1,130 @@ -# PROMPT_SLASH_COMMANDS in palette order: name category description -accounts Other List stored accounts; `dedupe` collapses duplicate profiles -attach Other Save and manage file attachments in the structured stash -chrome Integrations Browser automation via Chrome DevTools Protocol -clear Conversation Clear the conversation transcript -commit Project Stage and commit changes (model drafts the message) -compact Conversation Compact the conversation context -config Settings Open settings (theme, keybindings, advisor, import, UI) -connect Auth & Permissions Connect an AI provider -coven Coven Drive the local Coven daemon (sessions, harness runs, rituals) -diff Project Inspect the current git diff -effort Settings Set effort level (low/normal/high) or toggle fast mode -exit General Quit Coven -export Conversation Export, copy, or share the conversation -familiar Coven Your familiar & agents — switch persona, inspect, and manage -feedback General Open session feedback survey -handoff Coven Hand off current session context to a Coven familiar -help General Show help -hooks Integrations Browse configured hooks (read-only) -incant Tools & Extras Cast a speech incantation (caveman, rocky) or lift it with off -init Project Initialize AGENTS.md for this project -learn Tools & Extras Codify the script/workflow we just built into a reusable skill -link Other Save and manage links in the structured stash -login Auth & Permissions Log in, switch accounts, or refresh provider auth -logout Auth & Permissions Log out of Coven -mcp Integrations Browse configured MCP servers -memory Project Browse and open AGENTS.md memory files -model Settings Change the AI model -permissions Auth & Permissions Manage tool permission rules -plan AI & Thinking Enter plan mode (read-only) -plugin Tools & Extras Manage plugins (list/info/enable/disable/install/reload) -providers Auth & Permissions List available AI providers and their status -quit General Exit Coven -resume Sessions & Remote Resume a previous session -review Project Review changes (security/ultra variants, PR comments) -rewind Conversation Rewind the conversation or roll back file changes -sandbox Settings Toggle sandboxed shell execution -search Sessions & Remote Search the codebase by natural language or regex -splash Settings Show, hide, or toggle the empty-session splash screen -session Sessions & Remote Browse, rename, fork, branch, and tag sessions -settings Settings Open settings (theme, keybindings, advisor, import, UI) -skills Tools & Extras List and manage skills -status System Show session status or run diagnostics (/status doctor) -survey General Open session feedback survey -tasks AI & Thinking Manage tracked background tasks -thinking AI & Thinking Configure extended thinking or view past thinking traces -update System Check for updates and upgrade to the latest version -release-notes Other Show recent Coven highlights -usage Usage & Cost Detailed per-call token usage breakdown -version System Display the current Coven version -whisper Tools & Extras Whisper a side question to your familiar (not kept in history) - -# registered SlashCommands sorted by name: name category aliases hidden -accounts Other - false -add-dir Other - true -advisor AI & Thinking - true -agents Other - true -attach Other attachments false -branch Conversation - true -chrome Integrations - false -clear Conversation c,reset,new false -commit Project - false -compact Conversation - false -config Settings settings false -connect Auth & Permissions - false -copy Tools & Extras - true -coven Coven - false -diff Project - false -doctor System - true -effort Settings - false -exit General quit,q false -export Conversation - false -familiar Coven familiars,agent false -fast Settings speed true -feedback General bug false -fork Conversation - true -goal AI & Thinking - true -help General h,? false -hooks Integrations - false -import-config Project - true -incant Tools & Extras - false -init Project - false -keybindings Settings - true -learn Tools & Extras scribe false -link Other links false -login Auth & Permissions - false -logout Auth & Permissions - false -managed-agents Other - true -mcp Integrations - false -memory Project - false -model Settings - false -output-style Settings - true -permissions Auth & Permissions - false -plan AI & Thinking - false -plugin Tools & Extras plugins false -pr-comments Other - true -providers Auth & Permissions - false -refresh Auth & Permissions - true -release-notes Other whats-new,changelog false -reload-plugins Tools & Extras - true -resume Sessions & Remote r,continue false -review Project - false -rewind Conversation - false -sandbox Settings sandbox-toggle false -search Sessions & Remote - false -session Sessions & Remote remote false -share Sessions & Remote - true -skills Tools & Extras skill false -splash Settings welcome-screen false -status System - false -switch Auth & Permissions - true -tag Other - true -tasks AI & Thinking bashes false -theme Settings - true -think-back AI & Thinking thinkback true -thinking AI & Thinking think false -update System upgrade false -usage Usage & Cost - false -version System v false -whisper Tools & Extras btw false +# Canonical command catalog: path category state aliases usage summary +account Account & Models active - /account Manage accounts and provider connections +account connect Account & Models active - /account connect Connect a provider account +account dedupe Account & Models active - /account dedupe Remove duplicate credentials +account list Account & Models active - /account list List configured accounts +account login Account & Models active - /account login [--console] [--codex] [--label ] Sign in to a provider +account logout Account & Models active - /account logout Sign out of a provider +account providers Account & Models active - /account providers List available providers +account refresh Account & Models active - /account refresh Refresh provider authentication +account switch Account & Models active - /account switch [name] Switch the active account +accounts Account & Models removed:/account list - /accounts List configured accounts +accounts dedupe Account & Models removed:/account dedupe - /accounts dedupe Remove duplicate account profiles +add-dir Start & Navigate removed:/session add-dir - Use /session add-dir Removed compatibility command +advisor Customize removed:/config advisor - Use /config advisor Removed compatibility command +agents Coven & Advanced removed:/familiar list - Use /familiar list Removed compatibility command +attach Coven & Advanced removed:/stash attach attachments /attach Store and manage attachments +branch Start & Navigate removed:/session branch - Use /session branch Removed compatibility command +changes Work & Review removed:/diff - Use /diff Removed compatibility command +chrome Extend removed:/integrations chrome - /chrome Connect Chrome integration +clear Start & Navigate active c,reset,new /clear Clear the conversation history +commit Work & Review active - /commit [message] Ask Coven to commit staged changes +compact Conversation active - /compact Compact the conversation to reduce token usage +config Customize active settings /config [show|get|set|unset|] ... Show or modify configuration settings +config advisor Customize active - /config advisor [model|off] Configure the advisor model +config import Customize active - /config import Import configuration from another tool +config keybindings Customize active - /config keybindings Open keybinding configuration +config output-style Customize active - /config output-style [style] Select the response output style +config splash Customize active - /config splash [show|hide|toggle] Configure the empty-session splash screen +config theme Customize active - /config theme [name] Select the display theme +connect Account & Models removed:/account connect - /connect Connect a provider account +context Account & Models removed:/usage context - Use /usage context Removed compatibility command +copy Conversation removed:/export copy - Use /export copy Removed compatibility command +cost Account & Models removed:/usage cost - Use /usage cost Removed compatibility command +coven Coven & Advanced active - /coven [status|sessions|familiars|log|run|daemon|goal|help] ... Drive the local Coven daemon (sessions, harness runs, rituals) +coven goal Coven & Advanced active - /coven goal [objective|status|pause|resume|clear|complete] Manage durable session goals +diff Work & Review active - /diff Show git diff of changes in the working directory +doctor Coven & Advanced removed:/status doctor - Use /status doctor Removed compatibility command +effort Customize active - /effort [low|normal|high|fast [on|off]] Set the model's thinking effort (low | normal | high) +effort fast Customize active - /effort fast [on|off] Enable fast response mode +exit Start & Navigate active quit,q /exit Exit Coven +export Conversation active - /export [copy [n]|share] [--format markdown|json] [--output ] Export conversation to markdown or JSON +export copy Conversation active - /export copy Copy the session export to the clipboard +export share Conversation active - /export share Create a shareable session export +familiar Coven & Advanced active familiars,agent /familiar Your familiar & agents — switch persona, inspect, and manage +familiar handoff Coven & Advanced active - /familiar handoff Hand work to a familiar +familiar incant Coven & Advanced active - /familiar incant [arguments] Run a familiar incantation +familiar list Coven & Advanced active - /familiar list List available familiars +familiar managed Coven & Advanced active - /familiar managed [arguments] Manage familiar execution +familiar whisper Coven & Advanced active - /familiar whisper Ask a familiar side question +fast Customize removed:/effort fast speed Use /effort fast Removed compatibility command +feedback Extend removed:/help feedback bug,survey /feedback [report] Submit feedback about Coven +find Conversation advanced - /find Search the current transcript +fork Start & Navigate removed:/session fork - Use /session fork Removed compatibility command +goal Coven & Advanced removed:/coven goal - Use /coven goal Removed compatibility command +handoff Coven & Advanced removed:/familiar handoff - /handoff Hand work to a familiar +help Start & Navigate active h,? /help [command] Show available commands and usage information +help feedback Extend active - /help feedback [report] Submit feedback about Coven +hooks Extend removed:/integrations hooks - /hooks Configure lifecycle hooks +import-config Customize removed:/config import - Use /config import Removed compatibility command +incant Coven & Advanced removed:/familiar incant - /incant Run a familiar incantation +init Start & Navigate active - /init Initialize a new project with AGENTS.md +integrations Extend active - /integrations Configure external integrations +integrations chrome Extend active - /integrations chrome Connect Chrome integration +integrations hooks Extend active - /integrations hooks Configure lifecycle hooks +integrations mcp Extend active - /integrations mcp [arguments] Manage MCP servers +keybindings Customize removed:/config keybindings - Use /config keybindings Removed compatibility command +learn Extend active scribe /learn Codify a script or workflow we just built into a reusable skill (Hermes) +link Coven & Advanced removed:/stash link links /link Store and manage links +login Account & Models removed:/account login - /login Sign in to a provider +logout Account & Models removed:/account logout - /logout Sign out of a provider +managed-agents Coven & Advanced removed:/familiar managed - Use /familiar managed Removed compatibility command +mcp Extend removed:/integrations mcp - /mcp Manage MCP servers +memory Work & Review active - /memory [edit|clear] View, edit, or clear AGENTS.md memory files +model Account & Models active - /model [name] Show or change the current model +output-style Customize removed:/config output-style - Use /config output-style Removed compatibility command +permissions Account & Models active - /permissions [sandbox] View or change tool permission settings +permissions sandbox Account & Models active - /permissions sandbox Configure sandbox permissions +plan Conversation active - /plan [prompt] Enter plan mode – model outputs a plan for approval before acting +plugin Extend active plugins /plugin [reload] Manage plugins +plugin reload Extend active reload,plugin refresh,plugins reload,plugins refresh /plugin reload Reload installed plugins +pr-comments Work & Review removed:/review pr-comments - Use /review pr-comments Removed compatibility command +providers Account & Models removed:/account providers - /providers List available providers +refresh Account & Models removed:/account refresh - Use /account refresh Removed compatibility command +release-notes Coven & Advanced removed:/update notes whats-new,changelog /release-notes Show recent Coven highlights +reload-plugins Extend removed:/plugin reload - Use /plugin reload Removed compatibility command +rename Start & Navigate removed:/session rename - Use /session rename Removed compatibility command +resume Start & Navigate removed:/session resume r,continue /resume Resume a saved session +review Work & Review active - /review [pr-comments] Review code changes via LLM and optionally post to GitHub PR +review pr-comments Work & Review active - /review pr-comments Read pull request comments +review security Work & Review active - /review security [path] Run a security-focused review +review ultra Work & Review active - /review ultra [path] Run an exhaustive review +rewind Work & Review active - /rewind [list|diff|last] Rewind the conversation or roll back a turn's file changes +rewind diff Work & Review active - /rewind diff [checkpoint] Preview a recorded file-change diff +rewind list Work & Review active - /rewind list List recorded file-change checkpoints +sandbox Account & Models removed:/permissions sandbox sandbox-toggle /sandbox Configure sandbox permissions +search Start & Navigate active - /search Search across all sessions +session Start & Navigate active remote /session Show or manage conversation sessions +session add-dir Start & Navigate active - /session add-dir Add an allowed workspace directory +session branch Start & Navigate active - /session branch [arguments] Create a session branch +session fork Start & Navigate active - /session fork [message_index] Fork the current session +session rename Start & Navigate active - /session rename Rename the current session +session resume Start & Navigate active - /session resume [id] Resume a saved session +session tag Start & Navigate active - /session tag [arguments] Tag the current session +share Conversation removed:/export share - Use /export share Removed compatibility command +skills Extend active skill /skills List available skills in .coven-code/commands/ +splash Customize removed:/config splash welcome-screen /splash Configure splash screen +stash Coven & Advanced active - /stash Store useful links and attachments +stash attach Coven & Advanced active - /stash attach [arguments] Store and manage attachments +stash link Coven & Advanced active - /stash link [arguments] Store and manage links +stats Account & Models removed:/usage stats - Use /usage stats Removed compatibility command +status Coven & Advanced active - /status [doctor] Show comprehensive system and session status +status doctor Coven & Advanced active - /status doctor Run diagnostic checks +switch Account & Models removed:/account switch - Use /account switch Removed compatibility command +tag Start & Navigate removed:/session tag - Use /session tag Removed compatibility command +tasks Conversation active bashes /tasks List and manage background tasks +theme Customize removed:/config theme - Use /config theme Removed compatibility command +think-back Conversation removed:/thinking back thinkback Use /thinking back Removed compatibility command +thinking Conversation active think /thinking Toggle extended thinking mode +thinking back Conversation active - /thinking back View earlier thinking traces +update Coven & Advanced active upgrade /update Check for updates and download the latest release +update notes Coven & Advanced active - /update notes Show recent Coven release notes +update version Coven & Advanced active - /update version Show the installed Coven version +usage Account & Models active - /usage [cost|context|stats] Show API usage, quotas, and rate limit status +usage context Account & Models active - /usage context Show context window usage +usage cost Account & Models active - /usage cost Show session cost details +usage stats Account & Models active - /usage stats Show detailed session statistics +version Coven & Advanced removed:/update version v /version Show the installed Coven version +vim Customize advanced - /vim Toggle Vim input mode +voice Customize advanced - /voice Toggle voice input +whisper Coven & Advanced removed:/familiar whisper btw /whisper Ask a familiar side question diff --git a/src-rust/crates/commands/tests/slash_command_registry.rs b/src-rust/crates/commands/tests/slash_command_registry.rs index a263dacd..c9c73172 100644 --- a/src-rust/crates/commands/tests/slash_command_registry.rs +++ b/src-rust/crates/commands/tests/slash_command_registry.rs @@ -1,14 +1,7 @@ //! Slash-command registry golden. //! -//! Freezes the three things the TUI, the `/help` text, and the command -//! palette agree on: the autocomplete list (`PROMPT_SLASH_COMMANDS`), the -//! category each name maps to (`slash_command_category`), and the registered -//! `SlashCommand` set (names, aliases, hidden flag). Lives in this crate -//! because it is the one place both `claurst_commands::all_commands()` and -//! `claurst_tui::app` are reachable. -//! -//! The golden is for the default feature set. The opt-in `steer` entry is -//! filtered out so a `--features steer` build compares the same text. +//! Freezes the canonical catalog, including grouped paths, lifecycle, aliases, +//! category, usage, and descriptions. Feature-gated steering paths are excluded. //! //! Regenerate after a deliberate registry change (new command, rename, //! recategorisation) and review the diff in the PR: @@ -20,11 +13,13 @@ use std::fs; use std::path::Path; -use claurst_commands::all_commands; -use claurst_tui::app::{slash_command_category, PROMPT_SLASH_COMMANDS}; +use claurst_commands::{ + builtin_catalog, + catalog::{flatten_catalog, CommandLifecycle}, +}; /// Feature-gated names excluded from the golden so it is build-invariant. -const FEATURE_GATED: &[&str] = &["steer"]; +const FEATURE_GATED: &[&str] = &["steer", "thinking steer"]; fn assert_golden(path: &Path, actual: &str) { if std::env::var_os("UPDATE_GOLDENS").is_some() { @@ -55,35 +50,34 @@ fn assert_golden(path: &Path, actual: &str) { } fn render_registry() -> String { - let mut out = String::new(); - - out.push_str("# PROMPT_SLASH_COMMANDS in palette order: name\tcategory\tdescription\n"); - for (name, description) in PROMPT_SLASH_COMMANDS - .iter() - .filter(|(name, _)| !FEATURE_GATED.contains(name)) + let catalog = builtin_catalog(); + let mut entries = flatten_catalog(&catalog); + entries.sort_by(|left, right| left.path.cmp(&right.path)); + let mut out = String::from( + "# Canonical command catalog: path\tcategory\tstate\taliases\tusage\tsummary\n", + ); + for entry in entries + .into_iter() + .filter(|entry| !FEATURE_GATED.contains(&entry.path.as_str())) { + let state = match &entry.lifecycle { + CommandLifecycle::Active => "active".to_string(), + CommandLifecycle::Advanced => "advanced".to_string(), + CommandLifecycle::Removed { replacement } => format!("removed:/{replacement}"), + }; + let aliases = if entry.aliases.is_empty() { + "-".to_string() + } else { + entry.aliases.join(",") + }; out.push_str(&format!( - "{name}\t{}\t{description}\n", - slash_command_category(name) - )); - } - - out.push_str("\n# registered SlashCommands sorted by name: name\tcategory\taliases\thidden\n"); - let mut registered: Vec<(&str, Vec<&str>, bool)> = all_commands() - .iter() - .filter(|c| !FEATURE_GATED.contains(&c.name())) - .map(|c| (c.name(), c.aliases(), c.hidden())) - .collect(); - registered.sort_by(|a, b| a.0.cmp(b.0)); - for (name, aliases, hidden) in registered { - out.push_str(&format!( - "{name}\t{}\t{}\t{hidden}\n", - slash_command_category(name), - if aliases.is_empty() { - "-".to_string() - } else { - aliases.join(",") - }, + "{}\t{}\t{}\t{}\t{}\t{}\n", + entry.path, + entry.category.label(), + state, + aliases, + entry.usage.replace("steer|", ""), + entry.summary )); } out @@ -95,3 +89,13 @@ fn slash_command_registry_matches_golden() { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/slash_command_registry.txt"); assert_golden(&path, &render_registry()); } + +#[test] +#[cfg(not(feature = "steer"))] +fn generated_command_reference_is_current() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../docs/slash-commands.md"); + assert_eq!( + fs::read_to_string(path).expect("command reference"), + claurst_commands::catalog::render_reference() + ); +} diff --git a/src-rust/crates/core/src/command_context.rs b/src-rust/crates/core/src/command_context.rs new file mode 100644 index 00000000..ea9faaf8 --- /dev/null +++ b/src-rust/crates/core/src/command_context.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// Signals derived from the current session and environment for command discovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandContextSignal { + Unauthenticated, + EmptySession, + MissingAgentsFile, + GitDirty, + HasCompletedEdits, + RunningTurn, + HighContextUsage, + HasBackgroundTasks, + UpdateAvailable, + FamiliarAvailable, + CovenDaemonAvailable, +} diff --git a/src-rust/crates/core/src/git_utils.rs b/src-rust/crates/core/src/git_utils.rs index 207ab120..b336eb18 100644 --- a/src-rust/crates/core/src/git_utils.rs +++ b/src-rust/crates/core/src/git_utils.rs @@ -109,6 +109,20 @@ pub fn list_modified_files(repo_root: &Path) -> Vec { output.lines().map(|l| repo_root.join(l)).collect() } +/// Return whether tracked or untracked worktree changes are present. +/// +/// Non-repositories and command failures are treated as clean so contextual +/// command discovery never blocks on git availability. +pub fn is_worktree_dirty(cwd: &Path) -> bool { + Command::new("git") + .current_dir(cwd) + .args(["status", "--porcelain", "--untracked-files=normal"]) + .output() + .ok() + .filter(|output| output.status.success()) + .is_some_and(|output| !output.stdout.is_empty()) +} + // --------------------------------------------------------------------------- // Diff // --------------------------------------------------------------------------- @@ -250,7 +264,35 @@ pub fn is_ignored(repo_root: &Path, path: &Path) -> bool { #[cfg(test)] mod tests { use super::*; + use std::fs; use std::path::Path; + use tempfile::TempDir; + + fn new_repo() -> TempDir { + let dir = TempDir::new().expect("temp repo"); + let status = Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .expect("git init"); + assert!(status.success()); + dir + } + + #[test] + fn worktree_dirty_detects_clean_and_dirty_repositories() { + let repo = new_repo(); + assert!(!is_worktree_dirty(repo.path())); + + fs::write(repo.path().join("untracked.txt"), "dirty").expect("write untracked file"); + assert!(is_worktree_dirty(repo.path())); + } + + #[test] + fn worktree_dirty_is_false_outside_git_repository() { + let dir = TempDir::new().expect("temp non-repo"); + assert!(!is_worktree_dirty(dir.path())); + } #[test] fn get_repo_root_finds_git() { diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index c578471f..6516908b 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -3,6 +3,9 @@ // // All sub-modules are defined inline below. +pub mod command_context; +pub use command_context::CommandContextSignal; + // Branded provider / model identifier newtypes. pub mod provider_id; pub use provider_id::{ModelId, ProviderId}; diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 694a57b8..bf38edee 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -1,8 +1,12 @@ // app.rs — App state struct and main event loop. use crate::bridge_state::BridgeConnectionState; +use crate::command_palette::{ + command_category_order, flatten_command_catalog, rank_commands, CommandPaletteContext, + CommandPaletteEntry, +}; use crate::context_viz::ContextVizState; -use crate::dialog_select::{DialogSelectState, SelectItem}; +use crate::dialog_select::{DialogSelectState, SelectItem, SelectItemKind}; use crate::dialogs::McpApprovalDialogState; use crate::dialogs::PermissionRequest; use crate::diff_viewer::{build_turn_diff, DiffViewerState}; @@ -35,7 +39,7 @@ use claurst_core::keybindings::{ KeyContext, KeybindingResolver, KeybindingResult, ParsedKeystroke, UserKeybindings, }; use claurst_core::types::{ContentBlock, Message, Role}; -use claurst_core::{sample_completion_verb, sample_spinner_verb}; +use claurst_core::{sample_completion_verb, sample_spinner_verb, CommandContextSignal}; use claurst_query::QueryEvent; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::backend::CrosstermBackend; @@ -46,172 +50,6 @@ use std::io::Stdout; use std::sync::{Arc, Mutex}; use tracing::debug; -/// Pairs of (name, one-line description) shown by the slash-command -/// autocompletion, /help overlay, and the command palette. Must stay in -/// sync with the non-hidden entries returned by -/// `claurst_commands::all_commands()`; the -/// `prompt_slash_commands_covers_registry` test in `claurst-commands` -/// enforces that. -pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ - ( - "accounts", - "List stored accounts; `dedupe` collapses duplicate profiles", - ), - ( - "attach", - "Save and manage file attachments in the structured stash", - ), - ("chrome", "Browser automation via Chrome DevTools Protocol"), - ("clear", "Clear the conversation transcript"), - ( - "commit", - "Stage and commit changes (model drafts the message)", - ), - ("compact", "Compact the conversation context"), - ( - "config", - "Open settings (theme, keybindings, advisor, import, UI)", - ), - ("connect", "Connect an AI provider"), - ( - "coven", - "Drive the local Coven daemon (sessions, harness runs, rituals)", - ), - ("diff", "Inspect the current git diff"), - ( - "effort", - "Set effort level (low/normal/high) or toggle fast mode", - ), - ("exit", "Quit Coven"), - ("export", "Export, copy, or share the conversation"), - ( - "familiar", - "Your familiar & agents — switch persona, inspect, and manage", - ), - ("feedback", "Open session feedback survey"), - ( - "handoff", - "Hand off current session context to a Coven familiar", - ), - ("help", "Show help"), - ("hooks", "Browse configured hooks (read-only)"), - ( - "incant", - "Cast a speech incantation (caveman, rocky) or lift it with off", - ), - ("init", "Initialize AGENTS.md for this project"), - ( - "learn", - "Codify the script/workflow we just built into a reusable skill", - ), - ("link", "Save and manage links in the structured stash"), - ("login", "Log in, switch accounts, or refresh provider auth"), - ("logout", "Log out of Coven"), - ("mcp", "Browse configured MCP servers"), - ("memory", "Browse and open AGENTS.md memory files"), - ("model", "Change the AI model"), - ("permissions", "Manage tool permission rules"), - ("plan", "Enter plan mode (read-only)"), - ( - "plugin", - "Manage plugins (list/info/enable/disable/install/reload)", - ), - ("providers", "List available AI providers and their status"), - ("quit", "Exit Coven"), - ("resume", "Resume a previous session"), - ( - "review", - "Review changes (security/ultra variants, PR comments)", - ), - ( - "rewind", - "Rewind the conversation or roll back file changes", - ), - ("sandbox", "Toggle sandboxed shell execution"), - ("search", "Search the codebase by natural language or regex"), - ( - "splash", - "Show, hide, or toggle the empty-session splash screen", - ), - ("session", "Browse, rename, fork, branch, and tag sessions"), - ( - "settings", - "Open settings (theme, keybindings, advisor, import, UI)", - ), - ("skills", "List and manage skills"), - ( - "status", - "Show session status or run diagnostics (/status doctor)", - ), - #[cfg(feature = "steer")] - ( - "steer", - "Queue a steering message for the running turn (or send it now if idle)", - ), - ("survey", "Open session feedback survey"), - ("tasks", "Manage tracked background tasks"), - ( - "thinking", - "Configure extended thinking or view past thinking traces", - ), - ( - "update", - "Check for updates and upgrade to the latest version", - ), - ("release-notes", "Show recent Coven highlights"), - ("usage", "Detailed per-call token usage breakdown"), - ("version", "Display the current Coven version"), - ( - "whisper", - "Whisper a side question to your familiar (not kept in history)", - ), -]; - -/// Canonical category for a slash command. Single source of truth shared by -/// the F1 help overlay, the command palette, and the `/help` text output in -/// `claurst-commands` (which delegates here) so every surface groups -/// commands the same way. -pub fn slash_command_category(name: &str) -> &'static str { - match name { - "clear" | "compact" | "rewind" | "undo" | "revert" | "export" | "branch" | "fork" => { - "Conversation" - } - "model" | "config" | "settings" | "theme" | "color" | "vim" | "fast" | "effort" - | "voice" | "statusline" | "output-style" | "keybindings" | "splash" | "sandbox" => { - "Settings" - } - "cost" | "usage" | "context" | "stats" => "Usage & Cost", - "status" | "doctor" | "terminal-setup" | "version" | "update" | "upgrade" => "System", - "login" | "logout" | "switch" | "refresh" | "permissions" | "connect" | "providers" => { - "Auth & Permissions" - } - "memory" | "diff" | "init" | "commit" | "review" | "import-config" | "security-review" => { - "Project" - } - "mcp" | "hooks" | "chrome" => "Integrations", - "session" | "resume" | "search" | "share" | "rename" => "Sessions & Remote", - "help" | "exit" | "quit" | "feedback" | "survey" | "bug" => "General", - "think-back" | "thinking" | "plan" | "goal" | "tasks" | "advisor" => "AI & Thinking", - "copy" | "skills" | "learn" | "plugin" | "reload-plugins" | "whisper" | "incant" => { - "Tools & Extras" - } - "coven" | "familiar" | "familiars" | "handoff" => "Coven", - _ => "Other", - } -} - -fn help_overlay_entries() -> Vec { - PROMPT_SLASH_COMMANDS - .iter() - .map(|(name, description)| HelpEntry { - name: (*name).to_string(), - aliases: String::new(), - description: (*description).to_string(), - category: slash_command_category(name).to_string(), - }) - .collect() -} - // --------------------------------------------------------------------------- // Voice mode bootstrap // --------------------------------------------------------------------------- @@ -389,6 +227,8 @@ fn import_config_picker_items() -> Vec { title: "CLAUDE.md".into(), description: "Import ~/.claude/CLAUDE.md".into(), category: "Import".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, SelectItem { @@ -396,6 +236,8 @@ fn import_config_picker_items() -> Vec { title: "settings.json".into(), description: "Import ~/.claude/settings.json".into(), category: "Import".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, SelectItem { @@ -403,6 +245,8 @@ fn import_config_picker_items() -> Vec { title: "Both".into(), description: "Import both CLAUDE.md and settings.json".into(), category: "Import".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, ] @@ -417,6 +261,8 @@ fn provider_picker_items() -> Vec { description: "Run turns through the local claude binary; auth stays in Claude Code" .into(), category: "Claude".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: has_claude_cli.then(|| "LOCAL".into()), }, SelectItem { @@ -424,11 +270,22 @@ fn provider_picker_items() -> Vec { title: "Codex CLI".into(), description: "Sign in with Codex CLI login".into(), category: "Codex".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, ] } +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandPalettePage { + Top, + Category(String), + BrowseAll, + Custom, + Children(String), +} + // --------------------------------------------------------------------------- // Supporting types // --------------------------------------------------------------------------- @@ -997,6 +854,12 @@ pub struct App { pub import_config_dialog: ImportConfigDialogState, /// Ctrl+K command palette overlay. pub command_palette: DialogSelectState, + /// Shared source of truth for command palette, help, and prompt typeahead. + pub command_catalog: Vec, + command_palette_pages: Vec, + command_palette_context_snapshot: CommandPaletteContext, + /// Successfully executed canonical command paths, newest first. + pub recent_command_paths: std::collections::VecDeque, /// Whether Coven Code was launched from the user's home directory. /// Shown as a startup notice: "Note: You have launched Coven Code in your home directory…" pub home_dir_warning: bool, @@ -1395,11 +1258,7 @@ impl App { last_turn_verb: None, turn_metadata: Vec::new(), transcript_version: Cell::new(0), - help_overlay: { - let mut overlay = HelpOverlay::new(); - overlay.populate_from_commands(help_overlay_entries()); - overlay - }, + help_overlay: HelpOverlay::new(), history_search_overlay: HistorySearchOverlay::new(), global_search: GlobalSearchState::default(), message_selector: MessageSelectorOverlay::new(), @@ -1479,19 +1338,11 @@ impl App { import_config_picker_items(), ), import_config_dialog: ImportConfigDialogState::new(), - command_palette: { - let items: Vec = PROMPT_SLASH_COMMANDS - .iter() - .map(|(name, desc)| SelectItem { - id: format!("/{}", name), - title: format!("/{}", name), - description: desc.to_string(), - category: "Commands".to_string(), - badge: None, - }) - .collect(); - DialogSelectState::new("Command Palette", items) - }, + command_palette: DialogSelectState::new("Command Palette", Vec::new()), + command_catalog: Vec::new(), + command_palette_pages: vec![CommandPalettePage::Top], + command_palette_context_snapshot: CommandPaletteContext::default(), + recent_command_paths: std::collections::VecDeque::new(), home_dir_warning: false, output_style: "auto".to_string(), pr_number: None, @@ -2019,6 +1870,8 @@ impl App { title: "Codex CLI".into(), description: "Sign in with Codex CLI login".into(), category: "Codex".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }); } @@ -2260,16 +2113,27 @@ impl App { None => (args_trimmed, ""), }; match (cmd, sub, sub_rest) { - ("usage", "stats", _) => return self.intercept_slash_command("stats"), - ("config" | "settings", "theme", "") => return self.intercept_slash_command("theme"), - ("config" | "settings", "keybindings", _) => { - return self.intercept_slash_command("keybindings") + ("session" | "remote", "resume", "") => return self.intercept_slash_command("resume"), + ("usage", "cost", "") => return self.intercept_slash_command("cost"), + ("usage", "context", "") => return self.intercept_slash_command("context"), + ("help", "feedback", "") => return self.intercept_slash_command("feedback"), + ("account", "connect", _) => return self.open_connect_dialog(), + ("integrations", "hooks", _) => return self.open_hooks_config_menu(), + ("integrations", "mcp", "") => return self.open_mcp_view(), + ("integrations", "mcp", _) => return false, + ("usage", "stats", _) => return self.open_stats_dialog(), + ("config" | "settings", "theme", "") => return self.open_theme_screen(), + ("config" | "settings", "keybindings", _) => return self.open_keybindings_file(), + ("config" | "settings", "import", _) => { + return self.open_import_config_picker_surface() + } + ("export", "copy", "") => return self.copy_last_assistant_response(), + ("effort", "fast", "") => return self.toggle_fast_mode(), + ("config" | "settings", "output-style", "") => return self.cycle_output_style(), + ("config" | "settings", "splash", _) | ("permissions", "sandbox", _) => return false, + ("familiar" | "familiars" | "agent", "handoff", _) => { + return self.intercept_handoff(sub_rest) } - ("config" | "settings", "import" | "import-config", _) => { - return self.intercept_slash_command("import-config") - } - ("export", "copy", "") => return self.intercept_slash_command("copy"), - ("effort", "fast", _) => return self.intercept_slash_command("fast"), _ => {} } // `/skills` opens the interactive picker; `/skills ` pre-filters. @@ -2280,15 +2144,14 @@ impl App { if !args_trimmed.is_empty() && matches!(cmd, "config" | "settings" | "usage") { return false; } + if cmd == "help" && !args_trimmed.is_empty() { + return false; + } if cmd == "mcp" && !args_trimmed.is_empty() { return false; } - // Destructive roster wipes always go through the confirmation screen - // in the TUI: the unified `/familiar reset-roster` and the legacy - // `/agents reset` both land on the same dialog. - if (matches!(cmd, "familiar" | "familiars" | "agent") && args_trimmed == "reset-roster") - || (cmd == "agents" && args_trimmed == "reset") - { + // Destructive roster wipes always go through the confirmation screen. + if matches!(cmd, "familiar" | "familiars" | "agent") && args_trimmed == "reset-roster" { self.open_agents_menu(); self.agents_menu.route = AgentsRoute::ResetConfirm; return true; @@ -2305,7 +2168,6 @@ impl App { | "export" | "review" | "agent" - | "agents" | "familiar" | "familiars" | "status" @@ -2320,39 +2182,42 @@ impl App { return false; } if cmd == "handoff" { - let familiar = args.trim().to_string(); - if familiar.is_empty() { + return self.intercept_handoff(args_trimmed); + } + self.intercept_slash_command(cmd) + } + + fn intercept_handoff(&mut self, familiar: &str) -> bool { + if familiar.is_empty() { + self.push_notification( + NotificationKind::Warning, + "Usage: /familiar handoff ".to_string(), + Some(4), + ); + return true; + } + + let context = crate::handoff::build_handoff_context(&self.messages, familiar); + let root = self.project_root().to_string_lossy().to_string(); + match crate::handoff::send_handoff(familiar, context, &root) { + Ok(session_id) => { self.push_notification( - NotificationKind::Warning, - "Usage: /handoff ".to_string(), - Some(4), + NotificationKind::Info, + format!( + "Handed off to {familiar}. Session created in Coven daemon. (id: {session_id})" + ), + Some(5), ); - return true; } - - let context = crate::handoff::build_handoff_context(&self.messages, &familiar); - let root = self.project_root().to_string_lossy().to_string(); - match crate::handoff::send_handoff(&familiar, context, &root) { - Ok(session_id) => { - self.push_notification( - NotificationKind::Info, - format!( - "Handed off to {familiar}. Session created in Coven daemon. (id: {session_id})" - ), - Some(5), - ); - } - Err(err) => { - self.push_notification( - NotificationKind::Warning, - format!("Handoff failed: {err}"), - Some(5), - ); - } + Err(err) => { + self.push_notification( + NotificationKind::Warning, + format!("Handoff failed: {err}"), + Some(5), + ); } - return true; } - self.intercept_slash_command(cmd) + true } pub fn intercept_slash_command(&mut self, cmd: &str) -> bool { @@ -2363,30 +2228,10 @@ impl App { self.settings_screen.open(); true } - "theme" => { - let current = match &self.config.theme { - Theme::Dark => "dark", - Theme::Light => "light", - Theme::Default => "default", - Theme::Deuteranopia => "deuteranopia", - Theme::Custom(s) => s.as_str(), - }; - self.theme_screen.open(current); - true - } - "stats" => { - self.stats_dialog.open(); - true - } - "mcp" => { - let servers = self.load_mcp_servers(); - self.mcp_view.open(servers); - true - } - // One visual surface for familiars and agents: the bare command - // (canonical /familiar, alias /agent, deprecated /agents) opens - // the interactive menu; argument forms run in the commands crate. - "familiar" | "familiars" | "agent" | "agents" => { + "stats" => self.open_stats_dialog(), + "mcp" => self.open_mcp_view(), + // Canonical `/familiar` and its active aliases share this menu. + "familiar" | "familiars" | "agent" => { self.open_agents_menu(); true } @@ -2414,18 +2259,8 @@ impl App { self.memory_file_selector.open(&root); true } - "hooks" => { - self.hooks_config_menu.open(); - true - } - "import-config" => { - self.open_import_config_picker(); - true - } - "connect" => { - self.connect_dialog.open(); - true - } + "hooks" => self.open_hooks_config_menu(), + "connect" => self.open_connect_dialog(), "model" => { if !self.has_credentials { self.connect_dialog.open(); @@ -2475,16 +2310,6 @@ impl App { self.refresh_prompt_input(); true } - "fast" => { - self.fast_mode = !self.fast_mode; - let status = if self.fast_mode { - "enabled" - } else { - "disabled" - }; - self.status_message = Some(format!("Fast mode {}.", status)); - true - } "plan" => { use claurst_core::config::PermissionMode; self.plan_mode = !self.plan_mode; @@ -2505,51 +2330,6 @@ impl App { // Handled by execute_command in the CLI loop (real LLM compaction). false } - "copy" => { - // Copy last assistant message to clipboard. Attempt arboard; fall back to notification. - let last = self - .messages - .iter() - .rev() - .find(|m| m.role == Role::Assistant) - .map(|m| m.get_all_text()); - if let Some(text) = last { - // Try xclip/xsel/pbcopy/clip.exe for clipboard; fall back to notification. - let copied = try_copy_to_clipboard(&text); - if copied { - self.push_notification( - NotificationKind::Info, - "Copied to clipboard.".to_string(), - Some(3), - ); - } else { - self.push_notification( - NotificationKind::Info, - format!( - "Last response: {} chars (clipboard unavailable)", - text.len() - ), - Some(5), - ); - } - } else { - self.push_notification( - NotificationKind::Warning, - "No assistant message to copy.".to_string(), - Some(3), - ); - } - true - } - "output-style" => { - self.output_style = match self.output_style.as_str() { - "auto" => "stream".to_string(), - "stream" => "verbose".to_string(), - _ => "auto".to_string(), - }; - self.status_message = Some(format!("Output style: {}.", self.output_style)); - true - } "effort" => { // Open the picker dialog so users can pick an effort level // visually instead of cycling/typing the level (issue #149). @@ -2589,10 +2369,6 @@ impl App { } true } - "doctor" => { - // Handled by execute_command (DoctorCommand). - false - } "cost" => { self.stats_dialog.open(); true @@ -2619,16 +2395,6 @@ impl App { // Handled by execute_command (CLI-level operations). false } - "keybindings" => { - // Open the keybindings.json file in the external editor - let keybindings_path = - claurst_core::config::Settings::config_dir().join("keybindings.json"); - - if let Err(e) = open_file_externally(&keybindings_path) { - eprintln!("Failed to open keybindings file: {}", e); - } - true - } "help" => { // Open the help overlay (same as pressing `?` or F1). if !self.help_overlay.visible { @@ -2640,6 +2406,128 @@ impl App { } } + fn open_stats_dialog(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + self.stats_dialog.open(); + true + } + + fn open_mcp_view(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + let servers = self.load_mcp_servers(); + self.mcp_view.open(servers); + true + } + + fn open_theme_screen(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + let current = match &self.config.theme { + Theme::Dark => "dark", + Theme::Light => "light", + Theme::Default => "default", + Theme::Deuteranopia => "deuteranopia", + Theme::Custom(s) => s.as_str(), + }; + self.theme_screen.open(current); + true + } + + fn open_keybindings_file(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + let keybindings_path = Settings::config_dir().join("keybindings.json"); + if let Err(error) = open_file_externally(&keybindings_path) { + eprintln!("Failed to open keybindings file: {error}"); + } + true + } + + fn open_import_config_picker_surface(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + self.open_import_config_picker(); + true + } + + fn open_connect_dialog(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + self.connect_dialog.open(); + true + } + + fn open_hooks_config_menu(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + self.hooks_config_menu.open(); + true + } + + fn toggle_fast_mode(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + self.fast_mode = !self.fast_mode; + let status = if self.fast_mode { + "enabled" + } else { + "disabled" + }; + self.status_message = Some(format!("Fast mode {status}.")); + true + } + + fn copy_last_assistant_response(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + let last = self + .messages + .iter() + .rev() + .find(|m| m.role == Role::Assistant) + .map(|m| m.get_all_text()); + if let Some(text) = last { + let copied = try_copy_to_clipboard(&text); + if copied { + self.push_notification( + NotificationKind::Info, + "Copied to clipboard.".to_string(), + Some(3), + ); + } else { + self.push_notification( + NotificationKind::Info, + format!( + "Last response: {} chars (clipboard unavailable)", + text.len() + ), + Some(5), + ); + } + } else { + self.push_notification( + NotificationKind::Warning, + "No assistant message to copy.".to_string(), + Some(3), + ); + } + true + } + + fn cycle_output_style(&mut self) -> bool { + self.close_secondary_views(); + self.dismiss_error_notifications(); + self.output_style = match self.output_style.as_str() { + "auto" => "stream".to_string(), + "stream" => "verbose".to_string(), + _ => "auto".to_string(), + }; + self.status_message = Some(format!("Output style: {}.", self.output_style)); + true + } + fn close_secondary_views(&mut self) { self.stats_dialog.close(); self.mcp_view.close(); @@ -3399,14 +3287,388 @@ impl App { } let file_autocomplete_limit = self.config.file_autocomplete_limit; let file_autocomplete_show_hidden = self.config.file_autocomplete_show_hidden_files; - self.prompt_input.update_suggestions( - PROMPT_SLASH_COMMANDS, + let text_before_cursor = &self.prompt_input.text[..self.prompt_input.cursor]; + if text_before_cursor == "/" { + self.command_palette_context_snapshot = self.command_palette_context(); + } + self.prompt_input.update_suggestions_with_context( + &self.command_catalog, + &self.command_palette_context_snapshot, file_autocomplete_limit, file_autocomplete_show_hidden, ); self.sync_legacy_prompt_fields(); } + /// Install the live command catalog and refresh every command discovery surface. + pub fn install_command_catalog(&mut self, catalog: Vec) { + self.command_catalog = catalog; + self.rebuild_command_surfaces(); + self.refresh_prompt_input(); + } + + /// Use the command catalog for first-session guidance without probing disk during render. + pub fn onboarding_commands(&self) -> Vec<&CommandPaletteEntry> { + let mut context = CommandPaletteContext::default(); + context.signals.insert(CommandContextSignal::EmptySession); + if !self.has_credentials { + context + .signals + .insert(CommandContextSignal::Unauthenticated); + } + rank_commands(&self.command_catalog, "", &context, usize::MAX) + .into_iter() + .filter(|entry| { + entry.source == "built_in" + && entry.children.is_empty() + && entry + .suggest_when + .iter() + .any(|signal| context.signals.contains(signal)) + }) + .take(3) + .collect() + } + + fn rebuild_command_surfaces(&mut self) { + let visible = flatten_command_catalog(&self.command_catalog) + .into_iter() + .filter(|entry| !entry.path.is_empty()) + .collect::>(); + + self.help_overlay + .populate_from_commands(visible.into_iter().map(HelpEntry::from).collect()); + self.command_palette_pages = vec![CommandPalettePage::Top]; + let items = self.command_palette_page_items(); + self.command_palette = DialogSelectState::new("Command Palette", items); + } + + pub fn command_palette_context(&self) -> CommandPaletteContext { + let mut signals = std::collections::HashSet::new(); + let root = self.project_root(); + if !self.has_credentials { + signals.insert(CommandContextSignal::Unauthenticated); + } + if self.messages.is_empty() { + signals.insert(CommandContextSignal::EmptySession); + } + if !root.join("AGENTS.md").is_file() { + signals.insert(CommandContextSignal::MissingAgentsFile); + } + if claurst_core::git_utils::is_worktree_dirty(&root) { + signals.insert(CommandContextSignal::GitDirty); + } + if self.diff_viewer.has_turn_diff() { + signals.insert(CommandContextSignal::HasCompletedEdits); + } + if self.is_streaming { + signals.insert(CommandContextSignal::RunningTurn); + } + if self.context_window_size > 0 + && self.context_used_tokens >= self.context_window_size.saturating_mul(4) / 5 + { + signals.insert(CommandContextSignal::HighContextUsage); + } + if self.background_task_count > 0 { + signals.insert(CommandContextSignal::HasBackgroundTasks); + } + if self.update_available.is_some() { + signals.insert(CommandContextSignal::UpdateAvailable); + } + if !self.familiar_switcher_list.is_empty() { + signals.insert(CommandContextSignal::FamiliarAvailable); + } + if self.daemon_online { + signals.insert(CommandContextSignal::CovenDaemonAvailable); + } + + CommandPaletteContext { + signals, + recent_paths: self.recent_command_paths.iter().cloned().collect(), + } + } + + pub fn record_recent_command(&mut self, path: impl Into) { + let path = path.into(); + if !flatten_command_catalog(&self.command_catalog) + .into_iter() + .any(|entry| entry.path == path) + { + return; + } + self.recent_command_paths.retain(|recent| recent != &path); + self.recent_command_paths.push_front(path); + self.recent_command_paths.truncate(20); + self.command_palette_context_snapshot.recent_paths = + self.recent_command_paths.iter().cloned().collect(); + } + + pub fn unknown_command_suggestions(&self, query: &str) -> Vec { + rank_commands( + &self.command_catalog, + query, + &self.command_palette_context_snapshot, + 3, + ) + .into_iter() + .map(|entry| format!("/{}", entry.path)) + .collect() + } + + pub fn canonical_command_path(&self, name: &str, args: &str) -> Option { + let name = name.trim().trim_start_matches('/'); + let first_arg = args.split_whitespace().next(); + let full_path = first_arg + .map(|arg| format!("{name} {arg}")) + .unwrap_or_else(|| name.to_string()); + let entries = flatten_command_catalog(&self.command_catalog); + entries + .iter() + .find(|entry| { + entry.path == full_path || entry.aliases.iter().any(|alias| alias == &full_path) + }) + .or_else(|| { + entries.iter().find(|entry| { + entry.path == name || entry.aliases.iter().any(|alias| alias == name) + }) + }) + .map(|entry| entry.path.clone()) + } + + fn palette_command_item(entry: &CommandPaletteEntry, section: &str) -> SelectItem { + let aliases = if entry.aliases.is_empty() { + String::new() + } else { + format!(" aliases: {}", entry.aliases.join(", ")) + }; + SelectItem { + id: entry.path.clone(), + title: format!("/{}", entry.path), + description: format!("{}{}", entry.summary, aliases), + category: section.to_string(), + kind: SelectItemKind::Command, + keywords: entry.keywords.clone(), + badge: (!entry.children.is_empty()).then(|| "›".to_string()), + } + } + + fn palette_back_item() -> SelectItem { + SelectItem { + id: "__back__".to_string(), + title: "Back".to_string(), + description: "Return to the previous command list".to_string(), + category: "Navigation".to_string(), + kind: SelectItemKind::Back, + keywords: Vec::new(), + badge: Some("←".to_string()), + } + } + + fn command_palette_page_items(&self) -> Vec { + match self + .command_palette_pages + .last() + .unwrap_or(&CommandPalettePage::Top) + { + CommandPalettePage::Top => { + let mut items = rank_commands( + &self.command_catalog, + "", + &self.command_palette_context_snapshot, + usize::MAX, + ) + .into_iter() + .filter(|entry| entry.source == "built_in") + .take(8) + .map(|entry| Self::palette_command_item(entry, "Recommended now")) + .collect::>(); + + for category in command_category_order() { + let count = self + .command_catalog + .iter() + .filter(|entry| { + entry.source == "built_in" && entry.category.as_str() == *category + }) + .count(); + items.push(SelectItem { + id: (*category).to_string(), + title: (*category).to_string(), + description: format!("{count} commands"), + category: "Browse by category".to_string(), + kind: SelectItemKind::Category, + keywords: Vec::new(), + badge: None, + }); + } + + let builtin_count = self + .command_catalog + .iter() + .filter(|entry| entry.source == "built_in") + .count(); + items.push(SelectItem { + id: "__browse_all__".to_string(), + title: "Browse all commands".to_string(), + description: format!("{builtin_count} available"), + category: "Browse by category".to_string(), + kind: SelectItemKind::BrowseAll, + keywords: vec!["all".to_string()], + badge: Some(builtin_count.to_string()), + }); + + let custom_count = self + .command_catalog + .iter() + .filter(|entry| entry.source != "built_in") + .count(); + if custom_count > 0 { + items.push(SelectItem { + id: "__custom__".to_string(), + title: "Custom commands".to_string(), + description: format!("{custom_count} available"), + category: "Browse by category".to_string(), + kind: SelectItemKind::Category, + keywords: vec!["user skill plugin".to_string()], + badge: Some(custom_count.to_string()), + }); + } + items + } + CommandPalettePage::Category(category) => std::iter::once(Self::palette_back_item()) + .chain( + self.command_catalog + .iter() + .filter(|entry| entry.source == "built_in" && entry.category == *category) + .map(|entry| Self::palette_command_item(entry, category)), + ) + .collect(), + CommandPalettePage::BrowseAll => std::iter::once(Self::palette_back_item()) + .chain( + self.command_catalog + .iter() + .filter(|entry| entry.source == "built_in") + .map(|entry| Self::palette_command_item(entry, &entry.category)), + ) + .collect(), + CommandPalettePage::Custom => std::iter::once(Self::palette_back_item()) + .chain( + self.command_catalog + .iter() + .filter(|entry| entry.source != "built_in") + .map(|entry| Self::palette_command_item(entry, "Custom commands")), + ) + .collect(), + CommandPalettePage::Children(path) => { + let entry = flatten_command_catalog(&self.command_catalog) + .into_iter() + .find(|entry| entry.path == *path); + let mut items = vec![Self::palette_back_item()]; + if let Some(entry) = entry { + let mut parent = Self::palette_command_item(entry, &entry.category); + parent.title = format!("Run /{}", entry.path); + parent.badge = None; + items.push(parent); + items.extend( + entry + .children + .iter() + .map(|child| Self::palette_command_item(child, &child.category)), + ); + } + items + } + } + } + + fn refresh_command_palette_page(&mut self) { + let items = self.command_palette_page_items(); + self.command_palette.set_items(items); + } + + fn refresh_command_palette_query(&mut self) { + let query = self.command_palette.filter.trim().to_string(); + if query.is_empty() { + self.refresh_command_palette_page(); + return; + } + let items = rank_commands( + &self.command_catalog, + &query, + &self.command_palette_context_snapshot, + usize::MAX, + ) + .into_iter() + .filter(|entry| match self.command_palette_pages.last() { + Some(CommandPalettePage::Custom) => entry.source != "built_in", + Some(CommandPalettePage::Children(path)) => { + flatten_command_catalog(&self.command_catalog) + .into_iter() + .find(|candidate| candidate.path == path.as_str()) + .is_some_and(|parent| entry.source == parent.source) + } + _ => entry.source == "built_in", + }) + .map(|entry| Self::palette_command_item(entry, &entry.category)) + .collect(); + self.command_palette.set_items_pre_filtered(items); + } + + fn open_command_palette(&mut self) { + self.command_palette_context_snapshot = self.command_palette_context(); + self.command_palette_pages = vec![CommandPalettePage::Top]; + self.command_palette.open(); + self.refresh_command_palette_page(); + } + + fn command_palette_back(&mut self) { + if self.command_palette_pages.len() > 1 { + self.command_palette_pages.pop(); + self.command_palette.filter.clear(); + self.refresh_command_palette_page(); + } else { + self.command_palette.close(); + } + } + + fn command_palette_drill_selected(&mut self) { + let Some(selected) = self.command_palette.selected().cloned() else { + return; + }; + match selected.kind { + SelectItemKind::Command => { + let has_children = flatten_command_catalog(&self.command_catalog) + .into_iter() + .find(|entry| entry.path == selected.id) + .is_some_and(|entry| !entry.children.is_empty()); + if has_children { + self.command_palette_pages + .push(CommandPalettePage::Children(selected.id)); + } else { + return; + } + } + SelectItemKind::Category => { + if selected.id == "__custom__" { + self.command_palette_pages.push(CommandPalettePage::Custom); + } else { + self.command_palette_pages + .push(CommandPalettePage::Category(selected.id)); + } + } + SelectItemKind::BrowseAll => self + .command_palette_pages + .push(CommandPalettePage::BrowseAll), + SelectItemKind::Back => { + self.command_palette_back(); + return; + } + SelectItemKind::Action => return, + } + self.command_palette.filter.clear(); + self.refresh_command_palette_page(); + } + pub fn set_prompt_text(&mut self, text: String) { self.prompt_input.replace_text(text); self.refresh_prompt_input(); @@ -3771,8 +4033,9 @@ impl App { // the affordance visible. self.onboarding_dialog.dismiss(); let _ = Self::persist_onboarding_complete(); - self.status_message = - Some("Run /connect when you're ready to add a provider.".to_string()); + self.status_message = Some( + "Run /account connect when you're ready to add a provider.".to_string(), + ); } KeyCode::Enter | KeyCode::Right => { self.onboarding_dialog.dismiss(); @@ -4112,9 +4375,7 @@ impl App { // Command palette (Ctrl+K) if self.command_palette.visible { match key.code { - KeyCode::Esc => { - self.command_palette.close(); - } + KeyCode::Esc => self.command_palette_back(), KeyCode::Home => { self.command_palette.move_home(); } @@ -4141,17 +4402,54 @@ impl App { } KeyCode::Enter => { if let Some(selected) = self.command_palette.selected().cloned() { - self.command_palette.close(); - // Put the command in the input and signal for execution - self.prompt_input.replace_text(selected.id.clone()); - return true; // signal to submit this as input + match selected.kind { + SelectItemKind::Command => { + let in_own_children = matches!(self.command_palette_pages.last(), Some(CommandPalettePage::Children(path)) if path == &selected.id); + let has_children = flatten_command_catalog(&self.command_catalog) + .into_iter() + .any(|entry| { + entry.path == selected.id && !entry.children.is_empty() + }); + if has_children && !in_own_children { + self.command_palette_drill_selected(); + return false; + } + self.command_palette.close(); + self.prompt_input.replace_text(format!("/{}", selected.id)); + return true; + } + SelectItemKind::Category => { + if selected.id == "__custom__" { + self.command_palette_pages.push(CommandPalettePage::Custom); + } else { + self.command_palette_pages + .push(CommandPalettePage::Category(selected.id)); + } + self.command_palette.filter.clear(); + self.refresh_command_palette_page(); + } + SelectItemKind::BrowseAll => { + self.command_palette_pages + .push(CommandPalettePage::BrowseAll); + self.command_palette.filter.clear(); + self.refresh_command_palette_page(); + } + SelectItemKind::Back => self.command_palette_back(), + SelectItemKind::Action => {} + } } } KeyCode::Backspace => { - self.command_palette.filter_pop(); + if self.command_palette.filter.is_empty() { + self.command_palette_back(); + } else { + self.command_palette.filter_pop(); + self.refresh_command_palette_query(); + } } KeyCode::Char(c) => { self.command_palette.filter_push(c); + self.refresh_command_palette_query(); } _ => {} } @@ -5835,7 +6133,7 @@ impl App { } "openCommandPalette" => { if !self.is_streaming { - self.command_palette.open(); + self.open_command_palette(); } false } @@ -6262,7 +6560,7 @@ impl App { if let ContextMenuKind::Message { message_index } = kind { let branch_point = message_index + 1; self.prompt_input - .replace_text(format!("/fork {}", branch_point)); + .replace_text(format!("/session fork {}", branch_point)); self.status_message = Some(format!( "Fork at message {} - press Enter to confirm", branch_point @@ -7587,7 +7885,139 @@ mod tests { fn make_app() -> App { let config = Config::default(); let cost_tracker = claurst_core::cost::CostTracker::new(); - App::new(config, cost_tracker) + let mut app = App::new(config, cost_tracker); + app.install_command_catalog(test_command_catalog()); + app + } + + fn test_command_catalog() -> Vec { + ["account", "clear", "help", "model"] + .into_iter() + .map(|path| CommandPaletteEntry { + path: path.to_string(), + aliases: Vec::new(), + summary: format!("{path} summary"), + usage: format!("/{path}"), + category: "Test".to_string(), + keywords: vec![path.to_string()], + source: "test".to_string(), + suggest_when: Vec::new(), + children: Vec::new(), + }) + .collect() + } + + fn palette_entry( + path: &str, + category: &str, + source: &str, + keywords: &[&str], + children: Vec, + ) -> CommandPaletteEntry { + CommandPaletteEntry { + path: path.to_string(), + aliases: Vec::new(), + summary: format!("{path} summary"), + usage: format!("/{path}"), + category: category.to_string(), + keywords: keywords + .iter() + .map(|keyword| (*keyword).to_string()) + .collect(), + source: source.to_string(), + suggest_when: Vec::new(), + children, + } + } + + fn progressive_palette_catalog() -> Vec { + vec![ + palette_entry( + "session", + "Start & Navigate", + "built_in", + &["session"], + vec![palette_entry( + "session resume", + "Start & Navigate", + "built_in", + &["resume"], + Vec::new(), + )], + ), + palette_entry( + "review", + "Work & Review", + "built_in", + &["review"], + Vec::new(), + ), + palette_entry( + "thinking", + "Conversation", + "built_in", + &["thinking"], + Vec::new(), + ), + palette_entry( + "account", + "Account & Models", + "built_in", + &["account"], + Vec::new(), + ), + palette_entry( + "config", + "Customize", + "built_in", + &["settings"], + vec![palette_entry( + "config theme", + "Customize", + "built_in", + &["change theme"], + Vec::new(), + )], + ), + palette_entry("skills", "Extend", "built_in", &["skills"], Vec::new()), + palette_entry( + "status", + "Coven & Advanced", + "built_in", + &["status"], + Vec::new(), + ), + palette_entry("deploy", "Extend", "user", &["ship"], Vec::new()), + ] + } + + fn full_progressive_palette_catalog() -> Vec { + let mut catalog = progressive_palette_catalog(); + catalog.pop(); + catalog.extend((0..24).map(|index| { + palette_entry( + &format!("builtin-{index:02}"), + "Extend", + "built_in", + &["builtin"], + Vec::new(), + ) + })); + catalog.push(palette_entry( + "deploy", + "Extend", + "user", + &["ship"], + Vec::new(), + )); + catalog.push(palette_entry( + "audit", + "Extend", + "plugin", + &["audit"], + Vec::new(), + )); + catalog } fn press_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { @@ -8222,19 +8652,39 @@ role = "Research" assert!(!app.intercept_slash_command_with_args("review", "security")); assert!(!app.intercept_slash_command_with_args("status", "doctor")); assert!(!app.intercept_slash_command_with_args("thinking", "back")); + assert!(!app.intercept_slash_command_with_args("integrations", "mcp auth mcphub")); + assert!(!app.intercept_slash_command_with_args("config", "splash hide")); + assert!(!app.intercept_slash_command_with_args("permissions", "sandbox on")); // Bare forms keep their overlays. assert!(app.intercept_slash_command_with_args("rewind", "")); assert!(app.rewind_flow.visible); } #[test] - fn folded_subcommands_open_legacy_tui_surfaces() { + fn canonical_parent_subcommands_open_their_tui_surfaces() { let mut app = make_app(); assert!(app.intercept_slash_command_with_args("usage", "stats")); assert!(app.stats_dialog.visible); app.stats_dialog.close(); assert!(app.intercept_slash_command_with_args("config", "theme")); assert!(app.theme_screen.visible); + assert!(app.intercept_slash_command_with_args("account", "connect")); + assert!(app.connect_dialog.visible); + assert!(app.intercept_slash_command_with_args("integrations", "hooks")); + assert!(app.hooks_config_menu.visible); + assert!(app.intercept_slash_command_with_args("integrations", "mcp")); + assert!(app.mcp_view.visible); + assert!(app.intercept_slash_command_with_args("familiar", "handoff")); + assert!(app.intercept_slash_command_with_args("config", "import")); + assert!(app.import_config_picker.visible); + + assert_eq!(app.output_style, "auto"); + assert!(app.intercept_slash_command_with_args("config", "output-style")); + assert_eq!(app.output_style, "stream"); + + assert!(!app.fast_mode); + assert!(app.intercept_slash_command_with_args("effort", "fast")); + assert!(app.fast_mode); } #[test] @@ -8394,27 +8844,65 @@ role = "Research" } #[test] - fn test_fast_slash_command_toggles_fast_mode() { + fn effort_fast_toggles_fast_mode() { let mut app = make_app(); assert!(!app.fast_mode); - assert!(app.intercept_slash_command("fast")); + assert!(app.intercept_slash_command_with_args("effort", "fast")); assert!(app.fast_mode); - assert!(app.intercept_slash_command("fast")); + assert!(app.intercept_slash_command_with_args("effort", "fast")); assert!(!app.fast_mode); } #[test] - fn test_output_style_cycles() { + fn config_output_style_cycles() { let mut app = make_app(); assert_eq!(app.output_style, "auto"); - assert!(app.intercept_slash_command("output-style")); + assert!(app.intercept_slash_command_with_args("config", "output-style")); assert_eq!(app.output_style, "stream"); - assert!(app.intercept_slash_command("output-style")); + assert!(app.intercept_slash_command_with_args("config", "output-style")); assert_eq!(app.output_style, "verbose"); - assert!(app.intercept_slash_command("output-style")); + assert!(app.intercept_slash_command_with_args("config", "output-style")); assert_eq!(app.output_style, "auto"); } + #[test] + fn removed_roots_do_not_open_or_mutate_tui_surfaces() { + let removed = [ + "add-dir", + "advisor", + "agents", + "branch", + "copy", + "doctor", + "fast", + "fork", + "goal", + "import-config", + "keybindings", + "managed-agents", + "output-style", + "pr-comments", + "refresh", + "reload-plugins", + "share", + "switch", + "tag", + "theme", + "think-back", + ]; + + for command in removed { + let mut app = make_app(); + assert!( + !app.intercept_slash_command_with_args(command, ""), + "/{command} must not be intercepted" + ); + assert!(!app.any_modal_open(), "/{command} opened a TUI surface"); + assert!(!app.fast_mode, "/{command} mutated fast mode"); + assert_eq!(app.output_style, "auto", "/{command} mutated output style"); + } + } + #[test] fn test_context_menu_fork_targets_clicked_message() { let mut app = make_app(); @@ -8427,7 +8915,7 @@ role = "Research" ContextMenuKind::Message { message_index: 1 }, ); - assert_eq!(app.prompt_input.text, "/fork 2"); + assert_eq!(app.prompt_input.text, "/session fork 2"); assert_eq!( app.status_message.as_deref(), Some("Fork at message 2 - press Enter to confirm") @@ -8529,6 +9017,358 @@ role = "Research" assert!(app.help_overlay.visible); } + #[test] + fn install_command_catalog_rebuilds_help_palette_and_typeahead() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + assert!(app.command_catalog.is_empty()); + assert!(app.help_overlay.commands.is_empty()); + + let mut catalog = test_command_catalog(); + catalog[0].aliases = vec!["accounts".to_string()]; + app.install_command_catalog(catalog); + app.set_prompt_text("/acc".to_string()); + + assert!(app + .command_palette + .items + .iter() + .any(|item| item.kind == SelectItemKind::BrowseAll)); + assert_eq!(app.help_overlay.commands[0].aliases, "accounts"); + assert_eq!(app.prompt_input.suggestions[0].text, "/account"); + } + + #[test] + fn onboarding_uses_installed_catalog_and_auth_context() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + let mut connect = palette_entry( + "account connect", + "Account & Models", + "built_in", + &[], + Vec::new(), + ); + connect.summary = "Connect from the live catalog".to_string(); + connect.suggest_when = vec![CommandContextSignal::Unauthenticated]; + app.install_command_catalog(vec![connect]); + app.has_credentials = false; + assert_eq!( + app.onboarding_commands()[0].summary, + "Connect from the live catalog" + ); + app.has_credentials = true; + assert!(app.onboarding_commands().is_empty()); + } + + #[test] + fn command_palette_top_level_is_progressive_and_uses_builtin_counts() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog(full_progressive_palette_catalog()); + + app.open_command_palette(); + + let recommended = app + .command_palette + .items + .iter() + .filter(|item| item.kind == SelectItemKind::Command) + .count(); + let categories = app + .command_palette + .items + .iter() + .filter(|item| item.kind == SelectItemKind::Category && item.id != "__custom__") + .map(|item| item.title.as_str()) + .collect::>(); + let browse = app + .command_palette + .items + .iter() + .find(|item| item.kind == SelectItemKind::BrowseAll) + .expect("browse all row"); + let custom = app + .command_palette + .items + .iter() + .find(|item| item.id == "__custom__") + .expect("custom commands row"); + + assert!(recommended <= 8); + assert_eq!(categories, command_category_order()); + assert_eq!(browse.badge.as_deref(), Some("31")); + assert_eq!(browse.description, "31 available"); + assert_eq!(custom.badge.as_deref(), Some("2")); + } + + #[test] + fn command_palette_omits_custom_navigation_without_dynamic_commands() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + let mut catalog = progressive_palette_catalog(); + catalog.pop(); + app.install_command_catalog(catalog); + + app.open_command_palette(); + + assert!(!app + .command_palette + .items + .iter() + .any(|item| item.id == "__custom__")); + } + + #[test] + fn command_palette_drill_keys_handle_navigation_rows_and_command_parents() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog(full_progressive_palette_catalog()); + app.open_command_palette(); + + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.id == "Customize") + .expect("customize category"); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + assert_eq!(app.command_palette.items[0].kind, SelectItemKind::Back); + + app.command_palette.selected_index = 0; + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + assert!(app + .command_palette + .items + .iter() + .any(|item| item.kind == SelectItemKind::BrowseAll)); + + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.kind == SelectItemKind::BrowseAll) + .expect("browse all row"); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + assert_eq!( + app.command_palette + .items + .iter() + .filter(|item| item.kind == SelectItemKind::Command) + .count(), + 31 + ); + assert!(!app + .command_palette + .items + .iter() + .any(|item| matches!(item.id.as_str(), "deploy" | "audit"))); + + app.command_palette.selected_index = 0; + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.id == "__custom__") + .expect("custom commands row"); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + assert_eq!( + app.command_palette + .items + .iter() + .filter(|item| item.kind == SelectItemKind::Command) + .map(|item| item.id.as_str()) + .collect::>(), + vec!["deploy", "audit"] + ); + + app.command_palette_back(); + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.id == "Customize") + .expect("customize category"); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.id == "config") + .expect("config command"); + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + assert_eq!(app.command_palette.items[0].kind, SelectItemKind::Back); + assert!(app + .command_palette + .items + .iter() + .any(|item| item.id == "config theme")); + } + + #[test] + fn command_palette_enter_executes_parent_command() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog(progressive_palette_catalog()); + app.open_command_palette(); + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.id == "config") + .expect("recommended config command"); + + assert!(!app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + app.command_palette.selected_index = app + .command_palette + .items + .iter() + .position(|item| item.id == "config") + .expect("run parent row"); + assert!(app.handle_key_event(press_key(KeyCode::Enter, KeyModifiers::empty()))); + assert_eq!(app.prompt_input.text, "/config"); + assert!(!app.command_palette.visible); + } + + #[test] + fn command_palette_intent_query_searches_all_canonical_entries() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog(progressive_palette_catalog()); + app.open_command_palette(); + + for c in "change theme".chars() { + app.handle_key_event(press_key(KeyCode::Char(c), KeyModifiers::empty())); + } + + assert_eq!( + app.command_palette.selected().map(|item| item.id.as_str()), + Some("config theme") + ); + } + + #[test] + fn command_palette_context_computes_all_runtime_signals() { + let temp = tempfile::tempdir().expect("project dir"); + let status = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(temp.path()) + .status() + .expect("git init"); + assert!(status.success()); + std::fs::write(temp.path().join("dirty.txt"), "dirty").expect("dirty file"); + + let mut app = make_app(); + app.config.project_dir = Some(temp.path().to_path_buf()); + app.has_credentials = false; + app.is_streaming = true; + app.context_window_size = 100; + app.context_used_tokens = 80; + app.background_task_count = 1; + app.update_available = Some("9.9.9".to_string()); + app.familiar_switcher_list = vec!["ember".to_string()]; + app.daemon_online = true; + app.diff_viewer + .turn_files + .push(crate::diff_viewer::FileDiffStats { + path: "src/main.rs".to_string(), + added: 1, + removed: 0, + binary: false, + is_new_file: false, + hunks: Vec::new(), + }); + + let context = app.command_palette_context(); + + for signal in [ + CommandContextSignal::Unauthenticated, + CommandContextSignal::EmptySession, + CommandContextSignal::MissingAgentsFile, + CommandContextSignal::GitDirty, + CommandContextSignal::HasCompletedEdits, + CommandContextSignal::RunningTurn, + CommandContextSignal::HighContextUsage, + CommandContextSignal::HasBackgroundTasks, + CommandContextSignal::UpdateAvailable, + CommandContextSignal::FamiliarAvailable, + CommandContextSignal::CovenDaemonAvailable, + ] { + assert!(context.signals.contains(&signal), "missing {signal:?}"); + } + } + + #[test] + fn recent_commands_reject_unknown_paths_and_remain_unique_and_capped_at_twenty() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog( + (0..25) + .map(|index| { + palette_entry( + &format!("command-{index}"), + "Extend", + "built_in", + &["command"], + Vec::new(), + ) + }) + .collect(), + ); + app.record_recent_command("unknown"); + for index in 0..25 { + app.record_recent_command(format!("command-{index}")); + } + app.record_recent_command("command-10"); + + assert_eq!(app.recent_command_paths.len(), 20); + assert!(!app + .recent_command_paths + .iter() + .any(|path| path == "unknown")); + assert_eq!( + app.recent_command_paths.front().map(String::as_str), + Some("command-10") + ); + assert_eq!( + app.recent_command_paths + .iter() + .filter(|path| path.as_str() == "command-10") + .count(), + 1 + ); + } + + #[test] + fn unknown_command_suggestions_use_catalog_ranker() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog(vec![palette_entry( + "account login", + "Account & Models", + "built_in", + &["sign in"], + Vec::new(), + )]); + + assert_eq!( + app.unknown_command_suggestions("logn"), + vec!["/account login"] + ); + } + + #[test] + fn unknown_command_suggestions_are_capped_at_three() { + let mut app = App::new(Config::default(), claurst_core::cost::CostTracker::new()); + app.install_command_catalog( + (0..6) + .map(|index| { + palette_entry( + &format!("login-{index}"), + "Account & Models", + "built_in", + &["login"], + Vec::new(), + ) + }) + .collect(), + ); + + assert_eq!(app.unknown_command_suggestions("login").len(), 3); + } + #[test] fn test_help_slash_command_is_idempotent_when_already_open() { let mut app = make_app(); @@ -8572,6 +9412,18 @@ role = "Research" assert_eq!(app.prompt_input.text, "why?"); } + #[test] + fn help_filter_keeps_the_letter_f() { + let mut app = make_app(); + app.help_overlay.toggle(); + + app.handle_key_event(press_key(KeyCode::Char('f'), KeyModifiers::empty())); + + assert!(app.help_overlay.visible); + assert!(!app.feedback_survey.visible); + assert_eq!(app.help_overlay.filter, "f"); + } + #[test] fn test_ctrl_shift_a_shortcut_opens_model_picker() { let mut app = make_app(); diff --git a/src-rust/crates/tui/src/command_palette.rs b/src-rust/crates/tui/src/command_palette.rs new file mode 100644 index 00000000..b7cda38c --- /dev/null +++ b/src-rust/crates/tui/src/command_palette.rs @@ -0,0 +1,544 @@ +//! Presentation-only command catalog for TUI command discovery surfaces. + +use claurst_core::CommandContextSignal; +use std::collections::{HashMap, HashSet}; + +/// Owned command metadata consumed by the prompt, help overlay, and palette. +/// +/// This DTO deliberately does not expose command-crate types so the TUI stays +/// independent from command registration and execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandPaletteEntry { + pub path: String, + pub aliases: Vec, + pub summary: String, + pub usage: String, + pub category: String, + pub keywords: Vec, + pub source: String, + pub suggest_when: Vec, + pub children: Vec, +} + +impl CommandPaletteEntry { + /// Return this entry followed by its descendants in catalog order. + pub fn flattened(&self) -> Vec<&Self> { + let mut entries = vec![self]; + for child in &self.children { + entries.extend(child.flattened()); + } + entries + } +} + +/// Flatten a presentation catalog, preserving parent-before-child order. +pub fn flatten_command_catalog(catalog: &[CommandPaletteEntry]) -> Vec<&CommandPaletteEntry> { + catalog + .iter() + .flat_map(CommandPaletteEntry::flattened) + .collect() +} + +/// Session and environment signals used to rank command discovery results. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommandPaletteContext { + pub signals: HashSet, + pub recent_paths: Vec, +} + +const CATEGORY_ORDER: &[&str] = &[ + "Start & Navigate", + "Work & Review", + "Conversation", + "Account & Models", + "Customize", + "Extend", + "Coven & Advanced", +]; + +/// Stable user-facing command category order. +pub const fn command_category_order() -> &'static [&'static str] { + CATEGORY_ORDER +} + +fn category_rank(category: &str) -> usize { + CATEGORY_ORDER + .iter() + .position(|candidate| *candidate == category) + .unwrap_or(CATEGORY_ORDER.len()) +} + +fn recency_scores(paths: &[String]) -> HashMap<&str, i32> { + paths + .iter() + .enumerate() + .map(|(index, path)| (path.as_str(), (50 - (index as i32 * 5)).max(5))) + .collect() +} + +fn textual_score(entry: &CommandPaletteEntry, query: &str) -> Option { + let path = entry.path.to_lowercase(); + if path == query { + return Some(10_000); + } + if path.starts_with(query) { + return Some(8_000); + } + + let aliases = entry + .aliases + .iter() + .map(|alias| alias.to_lowercase()) + .collect::>(); + if aliases.iter().any(|alias| alias == query) { + return Some(7_500); + } + if aliases.iter().any(|alias| alias.starts_with(query)) { + return Some(7_000); + } + + let query_words = query.split_whitespace().collect::>(); + let searchable = std::iter::once(entry.path.as_str()) + .chain(entry.keywords.iter().map(String::as_str)) + .chain(std::iter::once(entry.summary.as_str())) + .chain(std::iter::once(entry.category.as_str())) + .collect::>() + .join(" ") + .to_lowercase(); + let searchable_words = searchable + .split(|character: char| !character.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>(); + if !query_words.is_empty() + && query_words + .iter() + .all(|word| searchable_words.contains(word)) + { + let path_words = path.split_whitespace().collect::>(); + let path_matches = query_words + .iter() + .filter(|word| path_words.contains(**word)) + .count() as i32; + return Some(5_000 + path_matches * 100); + } + + None +} + +fn edit_distance(left: &str, right: &str) -> usize { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut previous = (0..=right.len()).collect::>(); + let mut current = vec![0; right.len() + 1]; + + for (left_index, left_byte) in left.iter().enumerate() { + current[0] = left_index + 1; + for (right_index, right_byte) in right.iter().enumerate() { + current[right_index + 1] = if left_byte == right_byte { + previous[right_index] + } else { + 1 + previous[right_index] + .min(current[right_index]) + .min(previous[right_index + 1]) + }; + } + std::mem::swap(&mut previous, &mut current); + } + + previous[right.len()] +} + +fn typo_score(entry: &CommandPaletteEntry, query: &str) -> Option { + let candidates = std::iter::once(entry.path.as_str()) + .chain(entry.path.split_whitespace()) + .chain(entry.aliases.iter().map(String::as_str)); + let distance = candidates + .map(|candidate| edit_distance(query, &candidate.to_lowercase())) + .min()?; + let threshold = if query.len() <= 4 { 1 } else { 2 }; + (distance <= threshold).then_some(1_000 - distance as i32) +} + +fn score_entry( + entry: &CommandPaletteEntry, + query: &str, + context: &CommandPaletteContext, + recency: &HashMap<&str, i32>, + allow_typo: bool, +) -> Option { + let mut score = if query.is_empty() { + 0 + } else if let Some(score) = textual_score(entry, query) { + score + } else if allow_typo { + typo_score(entry, query)? + } else { + return None; + }; + + score += entry + .suggest_when + .iter() + .filter(|signal| context.signals.contains(signal)) + .count() as i32 + * 500; + score += recency.get(entry.path.as_str()).copied().unwrap_or(0); + if query.is_empty() && entry.category == "Coven & Advanced" { + score -= 1_000; + } + Some(score) +} + +/// Rank flattened canonical command entries deterministically. +/// +/// Canonical paths are deduplicated before the result is truncated. When no +/// direct intent match exists, a low-scoring edit-distance fallback keeps typo +/// suggestions such as `logn` useful without perturbing exact matches. +pub fn rank_commands<'a>( + catalog: &'a [CommandPaletteEntry], + query: &str, + context: &CommandPaletteContext, + limit: usize, +) -> Vec<&'a CommandPaletteEntry> { + let query = query.trim().trim_start_matches('/').trim().to_lowercase(); + let recency = recency_scores(&context.recent_paths); + let flattened = flatten_command_catalog(catalog); + let allow_typo = !query.is_empty() + && !flattened + .iter() + .any(|entry| textual_score(entry, &query).is_some()); + let mut seen = HashSet::new(); + let mut scored = flattened + .into_iter() + .filter(|entry| seen.insert(entry.path.as_str())) + .filter_map(|entry| { + score_entry(entry, &query, context, &recency, allow_typo).map(|score| (score, entry)) + }) + .collect::>(); + + scored.sort_by(|(left_score, left), (right_score, right)| { + right_score + .cmp(left_score) + .then_with(|| category_rank(&left.category).cmp(&category_rank(&right.category))) + .then_with(|| left.path.cmp(&right.path)) + }); + scored + .into_iter() + .take(limit) + .map(|(_, entry)| entry) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use claurst_core::CommandContextSignal::{EmptySession, MissingAgentsFile, Unauthenticated}; + use std::collections::HashSet; + + fn entry( + path: &str, + aliases: &[&str], + summary: &str, + category: &str, + keywords: &[&str], + suggest_when: &[claurst_core::CommandContextSignal], + ) -> CommandPaletteEntry { + CommandPaletteEntry { + path: path.to_string(), + aliases: aliases.iter().map(|alias| (*alias).to_string()).collect(), + summary: summary.to_string(), + usage: format!("/{path}"), + category: category.to_string(), + keywords: keywords + .iter() + .map(|keyword| (*keyword).to_string()) + .collect(), + source: "built_in".to_string(), + suggest_when: suggest_when.to_vec(), + children: Vec::new(), + } + } + + #[test] + fn flattening_preserves_parent_before_children() { + let catalog = vec![CommandPaletteEntry { + path: "review".to_string(), + aliases: vec!["r".to_string()], + summary: "Review changes".to_string(), + usage: "/review".to_string(), + category: "Work & Review".to_string(), + keywords: vec!["review".to_string()], + source: "built_in".to_string(), + suggest_when: Vec::new(), + children: vec![CommandPaletteEntry { + path: "review security".to_string(), + aliases: Vec::new(), + summary: "Review security".to_string(), + usage: "/review security".to_string(), + category: "Work & Review".to_string(), + keywords: vec!["security".to_string()], + source: "built_in".to_string(), + suggest_when: Vec::new(), + children: Vec::new(), + }], + }]; + + assert_eq!( + flatten_command_catalog(&catalog) + .into_iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["review", "review security"] + ); + } + + #[test] + fn empty_query_ranks_contextual_commands_and_caps_results() { + let mut account = entry( + "account", + &[], + "Manage accounts", + "Account & Models", + &["account"], + &[], + ); + account.children = vec![entry( + "account connect", + &["connect"], + "Connect a provider account", + "Account & Models", + &["connect", "auth"], + &[Unauthenticated, EmptySession, MissingAgentsFile], + )]; + let mut session = entry( + "session", + &[], + "Manage sessions", + "Start & Navigate", + &["session"], + &[], + ); + session.children = vec![entry( + "session resume", + &["resume"], + "Resume a saved session", + "Start & Navigate", + &["resume"], + &[EmptySession], + )]; + let mut catalog = vec![ + account, + session, + entry( + "init", + &[], + "Initialize project instructions", + "Start & Navigate", + &["agents", "project"], + &[EmptySession, MissingAgentsFile], + ), + ]; + catalog.extend((0..10).map(|index| { + entry( + &format!("other-{index}"), + &[], + "Other command", + "Extend", + &["other"], + &[], + ) + })); + let context = CommandPaletteContext { + signals: HashSet::from([Unauthenticated, EmptySession, MissingAgentsFile]), + recent_paths: Vec::new(), + }; + + let ranked = rank_commands(&catalog, "", &context, 8); + let paths = ranked + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(); + + assert!(ranked.len() <= 8); + assert_eq!(paths[0], "account connect"); + assert!(paths.contains(&"session resume")); + assert!(paths.contains(&"init")); + } + + #[test] + fn intent_queries_rank_expected_canonical_paths_first() { + let catalog = vec![ + entry( + "account login", + &["login"], + "Sign in to a provider", + "Account & Models", + &["sign in", "auth"], + &[], + ), + entry( + "config theme", + &["theme"], + "Select the display theme", + "Customize", + &["change theme", "appearance"], + &[], + ), + entry( + "rewind", + &[], + "Rewind recent changes", + "Work & Review", + &["go back", "undo"], + &[], + ), + ]; + let context = CommandPaletteContext::default(); + + for (query, expected) in [ + ("sign in", "account login"), + ("change theme", "config theme"), + ("go back", "rewind"), + ] { + assert_eq!( + rank_commands(&catalog, query, &context, 8)[0].path, + expected + ); + } + } + + #[test] + fn aliases_match_without_creating_duplicate_rows() { + let catalog = vec![ + entry( + "account login", + &["login", "sign-in"], + "Sign in", + "Account & Models", + &["auth"], + &[], + ), + entry( + "learn", + &["login guide"], + "Learn Coven", + "Extend", + &["guide"], + &[], + ), + ]; + + let ranked = rank_commands(&catalog, "login", &CommandPaletteContext::default(), 8); + let login_rows = ranked + .iter() + .filter(|entry| entry.path == "account login") + .count(); + + assert_eq!(ranked[0].path, "account login"); + assert_eq!(login_rows, 1); + } + + #[test] + fn textual_score_bands_have_exact_precedence() { + let exact_path = entry( + "login", + &[], + "Exact path", + "Start & Navigate", + &["exact"], + &[], + ); + let path_prefix = entry( + "login-now", + &[], + "Path prefix", + "Start & Navigate", + &["prefix"], + &[], + ); + let exact_alias = entry( + "account", + &["login"], + "Exact alias", + "Account & Models", + &["alias"], + &[], + ); + let alias_prefix = entry( + "connect", + &["login-now"], + "Alias prefix", + "Account & Models", + &["alias"], + &[], + ); + let whole_word = entry("help", &[], "Whole word", "Extend", &["login"], &[]); + let typo = entry("login", &[], "Typo", "Start & Navigate", &["typo"], &[]); + + let scores = [ + textual_score(&exact_path, "login"), + textual_score(&path_prefix, "login"), + textual_score(&exact_alias, "login"), + textual_score(&alias_prefix, "login"), + textual_score(&whole_word, "login"), + typo_score(&typo, "logn"), + ]; + + assert_eq!( + scores, + [ + Some(10_000), + Some(8_000), + Some(7_500), + Some(7_000), + Some(5_000), + Some(999), + ] + ); + assert!(scores.windows(2).all(|pair| pair[0] > pair[1])); + } + + #[test] + fn equal_scores_use_category_order_then_canonical_path() { + let catalog = vec![ + entry("zeta", &[], "Zeta", "Work & Review", &["same"], &[]), + entry("beta", &[], "Beta", "Start & Navigate", &["same"], &[]), + entry("alpha", &[], "Alpha", "Start & Navigate", &["same"], &[]), + ]; + + assert_eq!( + rank_commands(&catalog, "same", &CommandPaletteContext::default(), 8) + .into_iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["alpha", "beta", "zeta"] + ); + } + + #[test] + fn duplicate_canonical_paths_keep_only_the_first_catalog_entry() { + let catalog = vec![ + entry( + "review", + &[], + "First review", + "Work & Review", + &["review"], + &[], + ), + entry( + "review", + &["duplicate"], + "Duplicate review", + "Extend", + &["review"], + &[], + ), + ]; + + let ranked = rank_commands(&catalog, "review", &CommandPaletteContext::default(), 8); + + assert_eq!(ranked.len(), 1); + assert_eq!(ranked[0].summary, "First review"); + } +} diff --git a/src-rust/crates/tui/src/dialog_select.rs b/src-rust/crates/tui/src/dialog_select.rs index 838c8809..3c0d2653 100644 --- a/src-rust/crates/tui/src/dialog_select.rs +++ b/src-rust/crates/tui/src/dialog_select.rs @@ -19,6 +19,16 @@ use crate::overlays::{ // Types // --------------------------------------------------------------------------- +/// A single selectable item in the dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectItemKind { + Command, + Category, + BrowseAll, + Back, + Action, +} + /// A single selectable item in the dialog. #[derive(Debug, Clone)] pub struct SelectItem { @@ -26,6 +36,8 @@ pub struct SelectItem { pub title: String, pub description: String, pub category: String, + pub kind: SelectItemKind, + pub keywords: Vec, pub badge: Option, // e.g., "FREE", "LOCAL", "NEW" } @@ -77,6 +89,18 @@ impl DialogSelectState { self.visible = false; } + pub fn set_items(&mut self, items: Vec) { + self.items = items; + self.selected_index = 0; + self.refilter(); + } + + pub fn set_items_pre_filtered(&mut self, items: Vec) { + self.items = items; + self.selected_index = 0; + self.filtered_indices = (0..self.items.len()).collect(); + } + pub fn move_up(&mut self) { let count = self.filtered_indices.len(); if count == 0 { @@ -171,6 +195,10 @@ impl DialogSelectState { item.title.to_lowercase().contains(&query) || item.description.to_lowercase().contains(&query) || item.category.to_lowercase().contains(&query) + || item + .keywords + .iter() + .any(|keyword| keyword.to_lowercase().contains(&query)) }) .map(|(i, _)| i) .collect(); @@ -440,6 +468,8 @@ mod tests { title: "Anthropic".into(), description: "Claude models".into(), category: "Recommended".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, SelectItem { @@ -447,6 +477,8 @@ mod tests { title: "Codex".into(), description: "gpt-5.6 via ChatGPT".into(), category: "Recommended".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, SelectItem { @@ -454,6 +486,8 @@ mod tests { title: "Custom".into(), description: "Custom endpoint".into(), category: "Other".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, ] @@ -537,6 +571,18 @@ mod tests { assert_eq!(state.selected().unwrap().id, "anthropic"); } + #[test] + fn filter_matches_typed_keywords() { + let mut items = sample_items(); + items[2].keywords = vec!["bring your own endpoint".to_string()]; + let mut state = DialogSelectState::new("Test", items); + state.open(); + for c in "endpoint".chars() { + state.filter_push(c); + } + assert_eq!(state.selected().unwrap().id, "custom"); + } + #[test] fn page_up_and_down() { let mut state = DialogSelectState::new("Test", sample_items()); @@ -578,6 +624,8 @@ mod tests { title: "CLAUDE.md".into(), description: "Import ~/.claude/CLAUDE.md".into(), category: "Import".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, SelectItem { @@ -585,6 +633,8 @@ mod tests { title: "settings.json".into(), description: "Import ~/.claude/settings.json".into(), category: "Import".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: None, }, SelectItem { @@ -592,6 +642,8 @@ mod tests { title: "Both".into(), description: "Import both CLAUDE.md and settings.json".into(), category: "Import".into(), + kind: SelectItemKind::Action, + keywords: Vec::new(), badge: Some("SAFE".into()), }, ]; diff --git a/src-rust/crates/tui/src/diff_viewer.rs b/src-rust/crates/tui/src/diff_viewer.rs index 64c33953..95ddad27 100644 --- a/src-rust/crates/tui/src/diff_viewer.rs +++ b/src-rust/crates/tui/src/diff_viewer.rs @@ -243,6 +243,10 @@ impl DiffViewerState { } } + pub fn has_turn_diff(&self) -> bool { + !self.turn_files.is_empty() + } + fn open_for_type(&mut self, diff_type: DiffType, project_root: &std::path::Path) { self.diff_type = diff_type; self.reload_files(project_root); diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index 8b41c8bd..e8ff3dff 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -79,6 +79,8 @@ pub mod ask_user_dialog; pub mod bridge_state; /// Startup confirmation dialog for --dangerously-skip-permissions mode. pub mod bypass_permissions_dialog; +/// Presentation-only metadata for command discovery surfaces. +pub mod command_palette; /// Context window and rate-limit visualization overlay (/context). pub mod context_viz; @@ -197,7 +199,11 @@ pub use app::{try_copy_to_clipboard, App}; pub use bypass_permissions_dialog::{ render_bypass_permissions_dialog, BypassPermissionsDialogState, }; -pub use dialog_select::{render_dialog_select, DialogSelectState, SelectItem}; +pub use command_palette::{ + command_category_order, flatten_command_catalog, rank_commands, CommandPaletteContext, + CommandPaletteEntry, +}; +pub use dialog_select::{render_dialog_select, DialogSelectState, SelectItem, SelectItemKind}; pub use diff_viewer::{ load_git_diff, parse_unified_diff, render_diff_dialog, DiffPane, DiffType, DiffViewerState, }; @@ -394,7 +400,24 @@ mod tests { use tempfile::tempdir; fn make_app() -> App { - App::new(Config::default(), CostTracker::new()) + let mut app = App::new(Config::default(), CostTracker::new()); + app.install_command_catalog( + ["account", "clear", "help", "model"] + .into_iter() + .map(|path| CommandPaletteEntry { + path: path.to_string(), + aliases: Vec::new(), + summary: format!("{path} summary"), + usage: format!("/{path}"), + category: "Test".to_string(), + keywords: vec![path.to_string()], + source: "test".to_string(), + suggest_when: Vec::new(), + children: Vec::new(), + }) + .collect(), + ); + app } // ---- terminal capability policy -------------------------------------- @@ -513,7 +536,7 @@ mod tests { } #[test] - fn test_agents_slash_command_populates_active_agents() { + fn test_familiar_slash_command_populates_active_agents() { let mut app = make_app(); app.agent_status = vec![ ("Mendel".to_string(), "running".to_string()), @@ -521,7 +544,7 @@ mod tests { ("Plato".to_string(), "done".to_string()), ]; - assert!(app.intercept_slash_command("agents")); + assert!(app.intercept_slash_command("familiar")); assert!(app.agents_menu.visible); assert_eq!(app.agents_menu.active_agents.len(), 3); assert_eq!( @@ -640,7 +663,7 @@ mod tests { let mut app = make_app(); app.config.project_dir = Some(temp.path().to_path_buf()); - assert!(app.intercept_slash_command("agents")); + assert!(app.intercept_slash_command("familiar")); app.agents_menu.selected_row = 2; app.handle_key_event(key(KeyCode::Char('d'))); @@ -849,20 +872,12 @@ mod tests { #[test] fn test_tab_accepts_slash_suggestion() { - // The first `/a*` suggestion is whichever command comes alphabetically - // first in PROMPT_SLASH_COMMANDS. We don't pin a specific name here - // because the list grows over time; instead we assert that Tab - // completes to *some* `/a*` command — which is the real UX invariant. let mut app = make_app(); app.handle_key_event(key(KeyCode::Char('/'))); app.handle_key_event(key(KeyCode::Char('a'))); app.handle_key_event(key(KeyCode::Tab)); - assert!( - app.input.starts_with("/a"), - "expected Tab to complete to a /a* command, got {:?}", - app.input - ); + assert_eq!(app.input, "/account"); assert_eq!(app.input, app.prompt_input.text); assert_eq!(app.cursor_pos, app.input.len()); } diff --git a/src-rust/crates/tui/src/overlays.rs b/src-rust/crates/tui/src/overlays.rs index 55beb8f4..2f0c5640 100644 --- a/src-rust/crates/tui/src/overlays.rs +++ b/src-rust/crates/tui/src/overlays.rs @@ -12,6 +12,8 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget}; use ratatui::Frame; use unicode_width::UnicodeWidthStr; +use crate::command_palette::CommandPaletteEntry; + pub const COVEN_CODE_ACCENT: Color = Color::Rgb(139, 92, 246); pub const COVEN_CODE_APP_BG: Color = Color::Rgb(10, 10, 14); pub const COVEN_CODE_PANEL_BG: Color = Color::Rgb(20, 20, 28); @@ -302,18 +304,25 @@ pub struct HelpEntry { pub category: String, } +impl From<&CommandPaletteEntry> for HelpEntry { + fn from(entry: &CommandPaletteEntry) -> Self { + Self { + name: entry.path.clone(), + aliases: entry.aliases.join(", "), + description: entry.summary.clone(), + category: entry.category.clone(), + } + } +} + impl HelpOverlay { pub fn new() -> Self { Self::default() } - /// Populate (or replace) the command entries from the command registry. - /// Entries are sorted by category then name. + /// Populate (or replace) command entries in the catalog's canonical order. pub fn populate_from_commands(&mut self, entries: Vec) { self.commands = entries; - // Sort stable by category, then name for consistent display. - self.commands - .sort_by(|a, b| a.category.cmp(&b.category).then(a.name.cmp(&b.name))); } pub fn toggle(&mut self) { @@ -1994,6 +2003,40 @@ mod tests { assert_eq!(h.filter, "h"); } + #[test] + fn help_overlay_preserves_canonical_catalog_order() { + let categories = [ + "Start & Navigate", + "Work & Review", + "Conversation", + "Account & Models", + "Customize", + "Extend", + "Coven & Advanced", + ]; + let entries = categories + .iter() + .map(|category| HelpEntry { + name: format!("{} command", category.to_lowercase()), + aliases: String::new(), + description: String::new(), + category: (*category).to_string(), + }) + .collect(); + + let mut overlay = HelpOverlay::new(); + overlay.populate_from_commands(entries); + + assert_eq!( + overlay + .commands + .iter() + .map(|entry| entry.category.as_str()) + .collect::>(), + categories.to_vec() + ); + } + #[test] fn help_overlay_lists_hidden_keybinding_hints() { let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("terminal"); diff --git a/src-rust/crates/tui/src/prompt_input.rs b/src-rust/crates/tui/src/prompt_input.rs index 452c1c87..90f3a5fd 100644 --- a/src-rust/crates/tui/src/prompt_input.rs +++ b/src-rust/crates/tui/src/prompt_input.rs @@ -18,6 +18,8 @@ use ratatui::{ }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use crate::command_palette::{rank_commands, CommandPaletteContext, CommandPaletteEntry}; + const CLAUDE_ORANGE: Color = crate::overlays::COVEN_CODE_ACCENT; const PROMPT_POINTER: &str = "\u{276f}"; @@ -1507,13 +1509,29 @@ pub struct TypeaheadSuggestion { /// - `@` file references (e.g. `@src/`, `@~/Documents/`) pub fn compute_typeahead( input: &str, - slash_commands: &[(&str, &str)], + command_catalog: &[CommandPaletteEntry], + file_autocomplete_limit: usize, + file_autocomplete_show_hidden: bool, +) -> Vec { + compute_typeahead_with_context( + input, + command_catalog, + &CommandPaletteContext::default(), + file_autocomplete_limit, + file_autocomplete_show_hidden, + ) +} + +pub fn compute_typeahead_with_context( + input: &str, + command_catalog: &[CommandPaletteEntry], + command_context: &CommandPaletteContext, file_autocomplete_limit: usize, file_autocomplete_show_hidden: bool, ) -> Vec { // Handle slash commands: /help, /clear, etc. if input.starts_with('/') { - return compute_slash_suggestions(input, slash_commands); + return compute_slash_suggestions_with_context(input, command_catalog, command_context); } // Handle file references: @, @/, @~/, @src/, etc. @@ -1525,26 +1543,52 @@ pub fn compute_typeahead( } /// Compute typeahead suggestions for slash commands only (e.g., `/help`). +#[cfg(test)] pub(crate) fn compute_slash_suggestions( input: &str, - slash_commands: &[(&str, &str)], + command_catalog: &[CommandPaletteEntry], ) -> Vec { - let mut suggestions = Vec::new(); - - if let Some(cmd_prefix) = input.strip_prefix('/') { - let prefix_lower = cmd_prefix.to_lowercase(); - for (name, desc) in slash_commands { - if name.to_lowercase().starts_with(&prefix_lower) { - suggestions.push(TypeaheadSuggestion { - text: format!("/{}", name), - description: desc.to_string(), - source: TypeaheadSource::SlashCommand, - }); - } - } - } + compute_slash_suggestions_with_context( + input, + command_catalog, + &CommandPaletteContext::default(), + ) +} - suggestions +pub(crate) fn compute_slash_suggestions_with_context( + input: &str, + command_catalog: &[CommandPaletteEntry], + command_context: &CommandPaletteContext, +) -> Vec { + let query = input.strip_prefix('/').unwrap_or(input); + let normalized = query.to_lowercase(); + let entries = crate::command_palette::flatten_command_catalog(command_catalog); + let identifiers = entries + .iter() + .flat_map(|entry| { + std::iter::once(entry.path.as_str()).chain(entry.aliases.iter().map(String::as_str)) + }) + .collect::>(); + let has_command_prefix = identifiers.iter().any(|identifier| { + normalized + .strip_prefix(&identifier.to_lowercase()) + .is_some_and(|rest| rest.starts_with(char::is_whitespace)) + }); + if has_command_prefix + && !identifiers + .iter() + .any(|identifier| identifier.to_lowercase().starts_with(&normalized)) + { + return Vec::new(); + } + rank_commands(command_catalog, query, command_context, 8) + .into_iter() + .map(|entry| TypeaheadSuggestion { + text: format!("/{}", entry.path), + description: entry.summary.clone(), + source: TypeaheadSource::SlashCommand, + }) + .collect() } /// Compute typeahead suggestions for file references (e.g., `@src/main.rs`). @@ -3199,16 +3243,32 @@ impl PromptInputState { /// (for plain text) matching prompt-history entries. pub fn update_suggestions( &mut self, - slash_commands: &[(&str, &str)], + command_catalog: &[CommandPaletteEntry], + file_autocomplete_limit: usize, + file_autocomplete_show_hidden: bool, + ) { + self.update_suggestions_with_context( + command_catalog, + &CommandPaletteContext::default(), + file_autocomplete_limit, + file_autocomplete_show_hidden, + ); + } + + pub fn update_suggestions_with_context( + &mut self, + command_catalog: &[CommandPaletteEntry], + command_context: &CommandPaletteContext, file_autocomplete_limit: usize, file_autocomplete_show_hidden: bool, ) { // Only look at text up to the cursor — text after the cursor belongs to a // different editing position and would confuse rfind('@') / rfind('/'). let text_before_cursor = &self.text[..self.cursor]; - self.suggestions = compute_typeahead( + self.suggestions = compute_typeahead_with_context( text_before_cursor, - slash_commands, + command_catalog, + command_context, file_autocomplete_limit, file_autocomplete_show_hidden, ); @@ -3296,8 +3356,34 @@ impl PromptInputState { if let Some(idx) = self.suggestion_index { if let Some(s) = self.suggestions.get(idx) { let new_cursor = match s.source { - TypeaheadSource::SlashCommand | TypeaheadSource::History => { - // Replace entire text; discard anything after cursor too. + TypeaheadSource::SlashCommand => { + // Replace the command token under the cursor, preserving + // arguments when completion happens in the middle of input. + let token_end = self.text[self.cursor..] + .find(char::is_whitespace) + .map_or(self.text.len(), |offset| self.cursor + offset); + let mut tail = &self.text[token_end..]; + // If completion supplies grouped path words that already + // follow the cursor, consume those once before the arguments. + let prefix_words = self.text[..self.cursor].split_whitespace().count(); + for word in s.text.split_whitespace().skip(prefix_words) { + let remaining = tail.trim_start(); + match remaining.strip_prefix(word) { + Some(rest) + if rest.is_empty() || rest.starts_with(char::is_whitespace) => + { + tail = rest + } + _ => break, + } + } + let tail = tail.to_string(); + self.text = s.text.clone(); + let cursor = self.text.len(); + self.text.push_str(&tail); + cursor + } + TypeaheadSource::History => { self.text = s.text.clone(); self.text.len() } @@ -4355,13 +4441,63 @@ mod tests { const TEST_FILE_AUTOCOMPLETE_LIMIT: usize = 15; const TEST_FILE_AUTOCOMPLETE_SHOW_HIDDEN: bool = false; + #[test] + fn slash_completion_preserves_resolved_command_arguments() { + let catalog = test_catalog(&[("model", "Select model"), ("session rename", "Rename")]); + for input in ["/model x", "/session rename a"] { + let mut state = PromptInputState::new(); + state.replace_text(input.to_string()); + state.suggestions = compute_slash_suggestions(input, &catalog); + state.suggestion_index = Some(0); + state.accept_suggestion(); + assert_eq!(state.text, input); + } + assert!(!compute_slash_suggestions("/session r", &catalog).is_empty()); + } + + #[test] + fn slash_completion_preserves_arguments_after_cursor() { + let catalog = test_catalog(&[("model", "Select model"), ("session rename", "Rename")]); + for (input, cursor, expected) in [ + ("/model opus", 3, "/model opus"), + ("/session rename a", 10, "/session rename a"), + ("/session rename a", 3, "/session rename a"), + ] { + let mut state = PromptInputState::new(); + state.replace_text(input.to_string()); + state.cursor = cursor; + state.suggestions = compute_slash_suggestions(&input[..cursor], &catalog); + assert!(!state.suggestions.is_empty()); + state.suggestion_index = Some(0); + state.accept_suggestion(); + assert_eq!(state.text, expected); + } + } + + fn test_catalog(commands: &[(&str, &str)]) -> Vec { + commands + .iter() + .map(|(path, summary)| CommandPaletteEntry { + path: (*path).to_string(), + aliases: Vec::new(), + summary: (*summary).to_string(), + usage: format!("/{path}"), + category: "Test".to_string(), + keywords: Vec::new(), + source: "test".to_string(), + suggest_when: Vec::new(), + children: Vec::new(), + }) + .collect() + } + #[test] fn typeahead_slash_prefix_matches() { - let cmds = [ + let cmds = test_catalog(&[ ("help", "Show help"), ("history", "Show history"), ("compact", "Compact"), - ]; + ]); let suggestions = compute_slash_suggestions("/h", &cmds); assert_eq!(suggestions.len(), 2); assert_eq!(suggestions[0].text, "/help"); @@ -4370,7 +4506,7 @@ mod tests { #[test] fn typeahead_full_match() { - let cmds = [("compact", "Compact conversation")]; + let cmds = test_catalog(&[("compact", "Compact conversation")]); let suggestions = compute_slash_suggestions("/compact", &cmds); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].text, "/compact"); @@ -4379,22 +4515,134 @@ mod tests { #[test] fn typeahead_case_insensitive() { - let cmds = [("Help", "Show help")]; + let cmds = test_catalog(&[("Help", "Show help")]); let suggestions = compute_slash_suggestions("/H", &cmds); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].text, "/Help"); } + #[test] + fn typeahead_matches_aliases_and_active_child_paths_without_duplicates() { + let mut cmds = test_catalog(&[("review", "Review changes")]); + cmds[0].aliases = vec!["inspect".to_string()]; + cmds[0].children = test_catalog(&[("review security", "Review security")]); + cmds[0].children[0].aliases = vec![ + "review safe".to_string(), + "safe review".to_string(), + "security".to_string(), + ]; + + assert_eq!( + compute_slash_suggestions("/ins", &cmds) + .into_iter() + .map(|suggestion| suggestion.text) + .collect::>(), + vec!["/review"] + ); + assert_eq!( + compute_slash_suggestions("/review s", &cmds) + .into_iter() + .map(|suggestion| suggestion.text) + .collect::>(), + vec!["/review security"] + ); + assert_eq!( + compute_slash_suggestions("/safe", &cmds) + .into_iter() + .map(|suggestion| suggestion.text) + .collect::>(), + vec!["/review security"] + ); + assert_eq!( + compute_slash_suggestions("/sec", &cmds) + .into_iter() + .map(|suggestion| suggestion.text) + .collect::>(), + vec!["/review security"] + ); + } + + #[test] + fn top_level_slash_uses_contextual_recommendations_and_caps_results() { + use claurst_core::CommandContextSignal::Unauthenticated; + use std::collections::HashSet; + + let mut cmds = test_catalog(&[("account", "Manage accounts")]); + cmds[0].children = test_catalog(&[("account connect", "Connect a provider")]); + cmds[0].children[0].suggest_when = vec![Unauthenticated]; + for index in 0..12 { + cmds.push(CommandPaletteEntry { + path: format!("command-{index}"), + aliases: Vec::new(), + summary: "Other".to_string(), + usage: format!("/command-{index}"), + category: "Test".to_string(), + keywords: Vec::new(), + source: "test".to_string(), + suggest_when: Vec::new(), + children: Vec::new(), + }); + } + let context = CommandPaletteContext { + signals: HashSet::from([Unauthenticated]), + recent_paths: Vec::new(), + }; + + let suggestions = compute_slash_suggestions_with_context("/", &cmds, &context); + + assert!(suggestions.len() <= 8); + assert_eq!(suggestions[0].text, "/account connect"); + } + + #[test] + fn slash_typeahead_matches_intent_words() { + let mut cmds = test_catalog(&[("config theme", "Select the display theme")]); + cmds[0].keywords = vec!["change theme".to_string()]; + + let suggestions = compute_slash_suggestions("/change theme", &cmds); + + assert_eq!(suggestions[0].text, "/config theme"); + } + + #[test] + fn typeahead_folded_plugin_aliases_insert_canonical_reload_command() { + let mut cmds = test_catalog(&[("plugin", "Manage plugins")]); + cmds[0].aliases = vec!["plugins".to_string()]; + cmds[0].children = test_catalog(&[("plugin reload", "Reload installed plugins")]); + cmds[0].children[0].aliases = vec![ + "reload".to_string(), + "plugin refresh".to_string(), + "plugins reload".to_string(), + "plugins refresh".to_string(), + ]; + + for input in [ + "/reload", + "/plugin refresh", + "/plugins reload", + "/plugins refresh", + ] { + assert_eq!( + compute_slash_suggestions(input, &cmds) + .into_iter() + .map(|suggestion| suggestion.text) + .collect::>(), + vec!["/plugin reload"], + "{input} should insert the canonical folded command" + ); + } + } + // ---- suggestion navigation ----------------------------------------- #[test] fn suggestion_next_cycles() { let mut s = PromptInputState::new(); - let cmds = [ + let cmds = test_catalog(&[ ("help", "Help"), ("history", "History"), ("compact", "Compact"), - ]; + ]); s.text = "/h".to_string(); s.cursor = s.text.len(); s.update_suggestions(&cmds, 15, false); @@ -4409,7 +4657,7 @@ mod tests { #[test] fn accept_suggestion_fills_text() { let mut s = PromptInputState::new(); - let cmds = [("help", "Show help")]; + let cmds = test_catalog(&[("help", "Show help")]); s.text = "/he".to_string(); s.cursor = s.text.len(); s.update_suggestions(&cmds, 15, false); @@ -5463,7 +5711,7 @@ mod tests { #[test] fn file_autocomplete_slash_commands_still_work() { - let cmds = vec![("help", "Show help"), ("clear", "Clear messages")]; + let cmds = test_catalog(&[("help", "Show help"), ("clear", "Clear messages")]); let suggestions = compute_slash_suggestions("/he", &cmds); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].text, "/help"); diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index a60bb92c..efd8292f 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -2003,31 +2003,31 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) { // --- Right column: Tips + What's new, left-aligned like the image --- let right_w_usize = right_w.saturating_sub(1).max(1) as usize; - // The tip for a session is stable; selecting it re-reads tip history from - // disk, so do that once per process instead of on every frame. - static WELCOME_TIP: std::sync::OnceLock = std::sync::OnceLock::new(); - let tip_text = WELCOME_TIP.get_or_init(|| { - claurst_core::tips::select_tip(0) - .map(|t| t.content.to_string()) - .unwrap_or_else(|| "Edit AGENTS.md to add instructions for Coven".to_string()) - }); + let onboarding = app.onboarding_commands(); + let tip_text = onboarding + .first() + .map(|entry| format!("{} — {}", entry.usage, entry.summary)) + .unwrap_or_else(|| "Type / to discover commands for this session".to_string()); let mut right_lines: Vec = Vec::new(); // When unauthenticated, lead the right column with a prominent /connect // call-to-action so a brand-new user has an obvious next step. The tips and // what's-new sections still render below (kept for the startup chrome). - if !app.has_credentials { + if let Some(connect) = crate::command_palette::flatten_command_catalog(&app.command_catalog) + .into_iter() + .find(|entry| !app.has_credentials && entry.path == "account connect") + { let pink = crate::overlays::COVEN_CODE_ACCENT; right_lines.push(Line::from(vec![ Span::styled( - " /connect ", + format!(" {} ", connect.usage), Style::default() .fg(readable_fg_on(pink)) .bg(pink) .add_modifier(Modifier::BOLD), ), Span::styled( - " connect a provider to begin", + format!(" {}", connect.summary), Style::default().fg(pink).add_modifier(Modifier::BOLD), ), ])); @@ -2038,7 +2038,7 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) { ))); // Word-wrap the tip on word boundaries; when the CTA is also shown, keep it // to a single line so the fixed-height box doesn't clip What's new. - let tip_lines = crate::dialogs::word_wrap(tip_text, right_w_usize); + let tip_lines = crate::dialogs::word_wrap(&tip_text, right_w_usize); let tip_take = if app.has_credentials { tip_lines.len() } else { @@ -2072,7 +2072,11 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) { ))); } right_lines.push(Line::from(Span::styled( - "/release-notes for more", + crate::command_palette::flatten_command_catalog(&app.command_catalog) + .into_iter() + .find(|entry| entry.path == "update notes") + .map(|entry| format!("{} for more", entry.usage)) + .unwrap_or_else(|| "Type / for commands".to_string()), Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), @@ -2348,15 +2352,27 @@ fn render_input(frame: &mut Frame, app: &App, area: Rect, focused: bool) { } Line::from(spans) } else { + let connect = crate::command_palette::flatten_command_catalog(&app.command_catalog) + .into_iter() + .find(|entry| entry.path == "account connect"); Line::from(vec![ Span::styled( - " /connect ", + format!( + " {} ", + connect.map_or("Provider setup", |entry| entry.usage.as_str()) + ), Style::default() .fg(readable_fg_on(pink)) .bg(pink) .add_modifier(Modifier::BOLD), ), - Span::styled(" connect a provider", Style::default().fg(dim)), + Span::styled( + format!( + " {}", + connect.map_or("Type / for commands", |entry| entry.summary.as_str()) + ), + Style::default().fg(dim), + ), ]) }; @@ -4071,7 +4087,7 @@ mod welcome_tests { ); assert!(text.contains("What's new"), "missing what's new:\n{text}"); assert!( - text.contains("/release-notes for more"), + text.contains("Type / for commands"), "missing release-notes footer:\n{text}" ); } diff --git a/src-rust/crates/tui/tests/golden/classic/error_120x40.styles.txt b/src-rust/crates/tui/tests/golden/classic/error_120x40.styles.txt index 86ad1c6c..c5f858c2 100644 --- a/src-rust/crates/tui/tests/golden/classic/error_120x40.styles.txt +++ b/src-rust/crates/tui/tests/golden/classic/error_120x40.styles.txt @@ -1,13 +1,13 @@ abbbbbbbcccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa adddddeeeeeeeeeeeeeeeeeeeeeedddddabbbbbbbbbbbbbbbbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffddddddddddddddddddddddddddddddddddddddddda +adddddddddddgggggggggggddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada +adddddddddddhhhhhhhhhhhddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddddddiiiiiiiiiiiddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda +adddddddddddaaaaaaaaaaaddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddddddgggggggggggddddddddddafffffffffffffffdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddhhhhhhhhhhhddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada -adddddddddddiiiiiiiiiiiddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddaaaaaaaaaaaddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda -addddddddddddddddddddddddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda -addddddddddddffffffffddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddfffffffffffffffffffddddddajjjjjjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddffffffffddddddddddddajjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddfffffffffffffffffffddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddcccccccccdddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddddddddddddddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/src-rust/crates/tui/tests/golden/classic/error_120x40.txt b/src-rust/crates/tui/tests/golden/classic/error_120x40.txt index 92202873..b76e0071 100644 --- a/src-rust/crates/tui/tests/golden/classic/error_120x40.txt +++ b/src-rust/crates/tui/tests/golden/classic/error_120x40.txt @@ -1,13 +1,13 @@ ╭ Coven vX.Y.Z ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Welcome back baseline! │Tips for getting started │ -│ │Start with small features or bug fixes, tell Coven to propose a plan, and verify its │ -│ ▄ │suggested edits │ -│ ▄▄ ▄█▀█▄ ▄▄ │──────────────────────────────────────────────────────────────────────────────────── │ -│ █▀▀█▄ ▄█▀▀█ │What's new │ -│ ▀▄▄███▄▄▀ │ │ -│ │ │ -│ Opus 4.8 │ │ -│ Anthropic · offline │/release-notes for more │ +│ │Type / to discover commands for this session │ +│ ▄ │──────────────────────────────────────────────────────────────────────────────────── │ +│ ▄▄ ▄█▀█▄ ▄▄ │What's new │ +│ █▀▀█▄ ▄█▀▀█ │ │ +│ ▀▄▄███▄▄▀ │ │ +│ │ │ +│ Opus 4.8 │Type / for commands │ +│ Anthropic · offline │ │ │ ~/project │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src-rust/crates/tui/tests/golden/classic/error_80x24.styles.txt b/src-rust/crates/tui/tests/golden/classic/error_80x24.styles.txt index 9b9933a7..678631c7 100644 --- a/src-rust/crates/tui/tests/golden/classic/error_80x24.styles.txt +++ b/src-rust/crates/tui/tests/golden/classic/error_80x24.styles.txt @@ -1,13 +1,13 @@ abbbbbbbcccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa adddddeeeeeeeeeeeeeeeeeeeeeedddddabbbbbbbbbbbbbbbbbbbbbbbbddddddddddddddddddddda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffda -adddddddddddgggggggggggddddddddddafffffffffffffffffffffffffffffffffffffffdddddda -adddddddddddhhhhhhhhhhhddddddddddafffffffffffffffdddddddddddddddddddddddddddddda -adddddddddddiiiiiiiiiiiddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada -adddddddddddaaaaaaaaaaaddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddda +adddddddddddgggggggggggddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada +adddddddddddhhhhhhhhhhhddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddda +adddddddddddiiiiiiiiiiiddddddddddaffffffffffffffffffffffffffffffffffffffffffffda +adddddddddddaaaaaaaaaaaddddddddddaffffffffffffffffffffffffffffffffffffffffffffda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffda -addddddddddddjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjffffffffffffda -adddddddfffffjkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkjffffffffffffda +addddddddddddjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjddddddddddddda +adddddddfffffjkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkjddddddddddddda addddddddddddjllllllllllllllllllllllllllllllllllllllllllllllllllljddddddddddddda addddddddddddjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjddddddddddddda aaaaaaaaaaaaajjmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmjjaaaaaaaaaaaaaa diff --git a/src-rust/crates/tui/tests/golden/classic/error_80x24.txt b/src-rust/crates/tui/tests/golden/classic/error_80x24.txt index e51af639..091f475a 100644 --- a/src-rust/crates/tui/tests/golden/classic/error_80x24.txt +++ b/src-rust/crates/tui/tests/golden/classic/error_80x24.txt @@ -1,13 +1,13 @@ ╭ Coven vX.Y.Z ────────────────────────────────────────────────────────────────╮ │ Welcome back baseline! │Tips for getting started │ -│ │Start with small features or bug fixes, tell │ -│ ▄ │Coven to propose a plan, and verify its │ -│ ▄▄ ▄█▀█▄ ▄▄ │suggested edits │ -│ █▀▀█▄ ▄█▀▀█ │──────────────────────────────────────────── │ -│ ▀▄▄███▄▄▀ │What's new │ -│ │ │ -│ ╭───────────────────────────────────────────────────╮ommand, wit… │ -│ Anthr│ ⚠ Error │nd model la… │ +│ │Type / to discover commands for this session │ +│ ▄ │──────────────────────────────────────────── │ +│ ▄▄ ▄█▀█▄ ▄▄ │What's new │ +│ █▀▀█▄ ▄█▀▀█ │ │ +│ ▀▄▄███▄▄▀ │ │ +│ │ │ +│ ╭───────────────────────────────────────────────────╮ │ +│ Anthr│ ⚠ Error │ │ │ │───────────────────────────────────────────────────│ │ │ │ │ │ ╰────────────│ Baseline fixture: provider returned HTTP 500 │─────────────╯ diff --git a/src-rust/crates/tui/tests/golden/classic/idle_120x40.styles.txt b/src-rust/crates/tui/tests/golden/classic/idle_120x40.styles.txt index 13780796..a6c1eac3 100644 --- a/src-rust/crates/tui/tests/golden/classic/idle_120x40.styles.txt +++ b/src-rust/crates/tui/tests/golden/classic/idle_120x40.styles.txt @@ -1,13 +1,13 @@ abbbbbbbcccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa adddddeeeeeeeeeeeeeeeeeeeeeedddddabbbbbbbbbbbbbbbbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffddddddddddddddddddddddddddddddddddddddddda +adddddddddddgggggggggggddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada +adddddddddddhhhhhhhhhhhddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddddddiiiiiiiiiiiddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda +adddddddddddaaaaaaaaaaaddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddddddgggggggggggddddddddddafffffffffffffffdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddhhhhhhhhhhhddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada -adddddddddddiiiiiiiiiiiddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddaaaaaaaaaaaddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda -addddddddddddddddddddddddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda -addddddddddddffffffffddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddfffffffffffffffffffddddddajjjjjjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddffffffffddddddddddddajjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddfffffffffffffffffffddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddcccccccccdddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddddddddddddddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/src-rust/crates/tui/tests/golden/classic/idle_120x40.txt b/src-rust/crates/tui/tests/golden/classic/idle_120x40.txt index 81aa68c9..cd33f26b 100644 --- a/src-rust/crates/tui/tests/golden/classic/idle_120x40.txt +++ b/src-rust/crates/tui/tests/golden/classic/idle_120x40.txt @@ -1,13 +1,13 @@ ╭ Coven vX.Y.Z ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Welcome back baseline! │Tips for getting started │ -│ │Start with small features or bug fixes, tell Coven to propose a plan, and verify its │ -│ ▄ │suggested edits │ -│ ▄▄ ▄█▀█▄ ▄▄ │──────────────────────────────────────────────────────────────────────────────────── │ -│ █▀▀█▄ ▄█▀▀█ │What's new │ -│ ▀▄▄███▄▄▀ │ │ -│ │ │ -│ Opus 4.8 │ │ -│ Anthropic · offline │/release-notes for more │ +│ │Type / to discover commands for this session │ +│ ▄ │──────────────────────────────────────────────────────────────────────────────────── │ +│ ▄▄ ▄█▀█▄ ▄▄ │What's new │ +│ █▀▀█▄ ▄█▀▀█ │ │ +│ ▀▄▄███▄▄▀ │ │ +│ │ │ +│ Opus 4.8 │Type / for commands │ +│ Anthropic · offline │ │ │ ~/project │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src-rust/crates/tui/tests/golden/classic/idle_80x24.styles.txt b/src-rust/crates/tui/tests/golden/classic/idle_80x24.styles.txt index c801ba7c..e5a03d71 100644 --- a/src-rust/crates/tui/tests/golden/classic/idle_80x24.styles.txt +++ b/src-rust/crates/tui/tests/golden/classic/idle_80x24.styles.txt @@ -1,14 +1,14 @@ abbbbbbbcccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa adddddeeeeeeeeeeeeeeeeeeeeeedddddabbbbbbbbbbbbbbbbbbbbbbbbddddddddddddddddddddda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffda -adddddddddddgggggggggggddddddddddafffffffffffffffffffffffffffffffffffffffdddddda -adddddddddddhhhhhhhhhhhddddddddddafffffffffffffffdddddddddddddddddddddddddddddda -adddddddddddiiiiiiiiiiiddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada -adddddddddddaaaaaaaaaaaddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddda +adddddddddddgggggggggggddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada +adddddddddddhhhhhhhhhhhddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddda +adddddddddddiiiiiiiiiiiddddddddddaffffffffffffffffffffffffffffffffffffffffffffda +adddddddddddaaaaaaaaaaaddddddddddaffffffffffffffffffffffffffffffffffffffffffffda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffda -addddddddddddffffffffddddddddddddaffffffffffffffffffffffffffffffffffffffffffffda -adddddddfffffffffffffffffffddddddaffffffffffffffffffffffffffffffffffffffffffffda -addddddddddddcccccccccdddddddddddajjjjjjjjjjjjjjjjjjjjjjjdddddddddddddddddddddda +addddddddddddffffffffddddddddddddajjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddda +adddddddfffffffffffffffffffddddddaddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddcccccccccdddddddddddaddddddddddddddddddddddddddddddddddddddddddddda addddddddddddddddddddddddddddddddaddddddddddddddddddddddddddddddddddddddddddddda aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd diff --git a/src-rust/crates/tui/tests/golden/classic/idle_80x24.txt b/src-rust/crates/tui/tests/golden/classic/idle_80x24.txt index 769e9dd9..900a9e35 100644 --- a/src-rust/crates/tui/tests/golden/classic/idle_80x24.txt +++ b/src-rust/crates/tui/tests/golden/classic/idle_80x24.txt @@ -1,14 +1,14 @@ ╭ Coven vX.Y.Z ────────────────────────────────────────────────────────────────╮ │ Welcome back baseline! │Tips for getting started │ -│ │Start with small features or bug fixes, tell │ -│ ▄ │Coven to propose a plan, and verify its │ -│ ▄▄ ▄█▀█▄ ▄▄ │suggested edits │ -│ █▀▀█▄ ▄█▀▀█ │──────────────────────────────────────────── │ -│ ▀▄▄███▄▄▀ │What's new │ -│ │ │ -│ Opus 4.8 │ │ -│ Anthropic · offline │ │ -│ ~/project │/release-notes for more │ +│ │Type / to discover commands for this session │ +│ ▄ │──────────────────────────────────────────── │ +│ ▄▄ ▄█▀█▄ ▄▄ │What's new │ +│ █▀▀█▄ ▄█▀▀█ │ │ +│ ▀▄▄███▄▄▀ │ │ +│ │ │ +│ Opus 4.8 │Type / for commands │ +│ Anthropic · offline │ │ +│ ~/project │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/src-rust/crates/tui/tests/golden/classic/permission_120x40.styles.txt b/src-rust/crates/tui/tests/golden/classic/permission_120x40.styles.txt index b2b722ce..6d7765aa 100644 --- a/src-rust/crates/tui/tests/golden/classic/permission_120x40.styles.txt +++ b/src-rust/crates/tui/tests/golden/classic/permission_120x40.styles.txt @@ -1,13 +1,13 @@ abbbbbbbcccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa adddddeeeeeeeeeeeeeeeeeeeeeedddddabbbbbbbbbbbbbbbbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffddddddddddddddddddddddddddddddddddddddddda +adddddddddddgggggggggggddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada +adddddddddddhhhhhhhhhhhddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddddddiiiiiiiiiiiddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda +adddddddddddaaaaaaaaaaaddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddddddgggggggggggddddddddddafffffffffffffffdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddhhhhhhhhhhhddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada -adddddddddddiiiiiiiiiiiddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddaaaaaaaaaaaddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda -addddddddddddddddddddddddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda -addddddddddddffffffffddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddfffffffffffffffffffddddddajjjjjjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddffffffffddddddddddddajjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddfffffffffffffffffffddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddcccccccccdddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddddddddddddddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda aaaaaaaaaaaaaaaaaaaaklllllllllllllllllllllkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkaaaaaaaaaaaaaaaaaaaa diff --git a/src-rust/crates/tui/tests/golden/classic/permission_120x40.txt b/src-rust/crates/tui/tests/golden/classic/permission_120x40.txt index 2ef3c8f5..96bf963b 100644 --- a/src-rust/crates/tui/tests/golden/classic/permission_120x40.txt +++ b/src-rust/crates/tui/tests/golden/classic/permission_120x40.txt @@ -1,13 +1,13 @@ ╭ Coven vX.Y.Z ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Welcome back baseline! │Tips for getting started │ -│ │Start with small features or bug fixes, tell Coven to propose a plan, and verify its │ -│ ▄ │suggested edits │ -│ ▄▄ ▄█▀█▄ ▄▄ │──────────────────────────────────────────────────────────────────────────────────── │ -│ █▀▀█▄ ▄█▀▀█ │What's new │ -│ ▀▄▄███▄▄▀ │ │ -│ │ │ -│ Opus 4.8 │ │ -│ Anthropic · offline │/release-notes for more │ +│ │Type / to discover commands for this session │ +│ ▄ │──────────────────────────────────────────────────────────────────────────────────── │ +│ ▄▄ ▄█▀█▄ ▄▄ │What's new │ +│ █▀▀█▄ ▄█▀▀█ │ │ +│ ▀▄▄███▄▄▀ │ │ +│ │ │ +│ Opus 4.8 │Type / for commands │ +│ Anthropic · offline │ │ │ ~/project │ │ │ │ │ ╰───────────────────┌ Permission Required ─────────────────────────────────────────────────────────┐───────────────────╯ diff --git a/src-rust/crates/tui/tests/golden/classic/streaming_120x40.styles.txt b/src-rust/crates/tui/tests/golden/classic/streaming_120x40.styles.txt index 291d971c..5ea15294 100644 --- a/src-rust/crates/tui/tests/golden/classic/streaming_120x40.styles.txt +++ b/src-rust/crates/tui/tests/golden/classic/streaming_120x40.styles.txt @@ -1,13 +1,13 @@ abbbbbbbcccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa adddddeeeeeeeeeeeeeeeeeeeeeedddddabbbbbbbbbbbbbbbbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffddddddddddddddddddddddddddddddddddddddddda +adddddddddddgggggggggggddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada +adddddddddddhhhhhhhhhhhddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddddddiiiiiiiiiiiddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda +adddddddddddaaaaaaaaaaaddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda addddddddddddddddddddddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddddddgggggggggggddddddddddafffffffffffffffdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddhhhhhhhhhhhddddddddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaada -adddddddddddiiiiiiiiiiiddddddddddabbbbbbbbbbddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda -adddddddddddaaaaaaaaaaaddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffddddddda -addddddddddddddddddddddddddddddddafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdddddddddddddddddda -addddddddddddffffffffddddddddddddaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffda -adddddddfffffffffffffffffffddddddajjjjjjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +addddddddddddffffffffddddddddddddajjjjjjjjjjjjjjjjjjjdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda +adddddddfffffffffffffffffffddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddcccccccccdddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda addddddddddddddddddddddddddddddddaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddda aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/src-rust/crates/tui/tests/golden/classic/streaming_120x40.txt b/src-rust/crates/tui/tests/golden/classic/streaming_120x40.txt index 43d8b52e..2e17ff47 100644 --- a/src-rust/crates/tui/tests/golden/classic/streaming_120x40.txt +++ b/src-rust/crates/tui/tests/golden/classic/streaming_120x40.txt @@ -1,13 +1,13 @@ ╭ Coven vX.Y.Z ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Welcome back baseline! │Tips for getting started │ -│ │Start with small features or bug fixes, tell Coven to propose a plan, and verify its │ -│ ▄ │suggested edits │ -│ ▄▄ ▄█▀█▄ ▄▄ │──────────────────────────────────────────────────────────────────────────────────── │ -│ █▀▀█▄ ▄█▀▀█ │What's new │ -│ ▀▄▄███▄▄▀ │ │ -│ │ │ -│ Opus 4.8 │ │ -│ Anthropic · offline │/release-notes for more │ +│ │Type / to discover commands for this session │ +│ ▄ │──────────────────────────────────────────────────────────────────────────────────── │ +│ ▄▄ ▄█▀█▄ ▄▄ │What's new │ +│ █▀▀█▄ ▄█▀▀█ │ │ +│ ▀▄▄███▄▄▀ │ │ +│ │ │ +│ Opus 4.8 │Type / for commands │ +│ Anthropic · offline │ │ │ ~/project │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯