From 23aa2fb87e7a5e8fe152f65a5da11aa58cacc8e1 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:02:28 -0500 Subject: [PATCH 1/2] feat(commands): unify contextual command discovery Use a canonical catalog for grouped command discovery, contextual suggestions, help, onboarding, and generated reference docs. Retire expired command paths with explicit replacements and preserve existing command arguments through completion. Closes #178 Coven-Harness: Codex / GPT-6 --- docs/slash-commands.md | 190 ++ scripts/tui-tests/README.md | 4 +- scripts/tui-tests/cases/03_command_palette.sh | 45 +- scripts/tui-tests/cases/04_help_overlay.sh | 13 +- scripts/tui-tests/cases/07_paste_burst.sh | 2 +- src-rust/crates/cli/Cargo.toml | 2 +- src-rust/crates/cli/src/main.rs | 74 +- src-rust/crates/commands/Cargo.toml | 1 + .../commands/examples/command_reference.rs | 14 + src-rust/crates/commands/src/catalog.rs | 1563 +++++++++++++++ .../crates/commands/src/grouped_commands.rs | 138 ++ src-rust/crates/commands/src/lib.rs | 1678 +++++++++++++---- .../tests/golden/slash_command_registry.txt | 250 +-- .../commands/tests/slash_command_registry.rs | 82 +- src-rust/crates/core/src/command_context.rs | 18 + src-rust/crates/core/src/git_utils.rs | 42 + src-rust/crates/core/src/lib.rs | 3 + src-rust/crates/tui/src/app.rs | 1565 +++++++++++---- src-rust/crates/tui/src/command_palette.rs | 544 ++++++ src-rust/crates/tui/src/dialog_select.rs | 52 + src-rust/crates/tui/src/diff_viewer.rs | 4 + src-rust/crates/tui/src/lib.rs | 43 +- src-rust/crates/tui/src/overlays.rs | 53 +- src-rust/crates/tui/src/prompt_input.rs | 310 ++- src-rust/crates/tui/src/render.rs | 32 +- .../golden/classic/error_120x40.styles.txt | 14 +- .../tui/tests/golden/classic/error_120x40.txt | 16 +- .../golden/classic/error_80x24.styles.txt | 12 +- .../tui/tests/golden/classic/error_80x24.txt | 16 +- .../golden/classic/idle_120x40.styles.txt | 14 +- .../tui/tests/golden/classic/idle_120x40.txt | 16 +- .../golden/classic/idle_80x24.styles.txt | 14 +- .../tui/tests/golden/classic/idle_80x24.txt | 18 +- .../classic/permission_120x40.styles.txt | 14 +- .../golden/classic/permission_120x40.txt | 16 +- .../classic/streaming_120x40.styles.txt | 14 +- .../tests/golden/classic/streaming_120x40.txt | 16 +- 37 files changed, 5817 insertions(+), 1085 deletions(-) create mode 100644 docs/slash-commands.md create mode 100644 src-rust/crates/commands/examples/command_reference.rs create mode 100644 src-rust/crates/commands/src/catalog.rs create mode 100644 src-rust/crates/commands/src/grouped_commands.rs create mode 100644 src-rust/crates/core/src/command_context.rs create mode 100644 src-rust/crates/tui/src/command_palette.rs 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..6353592b 100644 --- a/scripts/tui-tests/cases/03_command_palette.sh +++ b/scripts/tui-tests/cases/03_command_palette.sh @@ -10,20 +10,17 @@ 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" @@ -39,12 +36,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..2230038e 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); } @@ -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(); @@ -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 7d32b228..2b79cfae 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") - } - ("config" | "settings", "import" | "import-config", _) => { - return self.intercept_slash_command("import-config") + ("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) } - ("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(); @@ -4112,9 +4374,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 +4401,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 +6132,7 @@ impl App { } "openCommandPalette" => { if !self.is_streaming { - self.command_palette.open(); + self.open_command_palette(); } false } @@ -6262,7 +6559,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 @@ -7487,7 +7784,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 { @@ -8122,19 +8551,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] @@ -8294,27 +8743,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(); @@ -8327,7 +8814,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") @@ -8429,6 +8916,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(); @@ -8472,6 +9311,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 dd7236f5..f55fdc06 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), @@ -4071,7 +4075,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 │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ From 41fd91ee96547cee45020711c342930558284677 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:17:50 -0500 Subject: [PATCH 2/2] fix(commands): synchronize discovery smoke checks and setup hints Wait for the filtered frame instead of an entry already absent from contextual suggestions. Source the unauthenticated composer hint from the catalog and point setup guidance at the canonical account command. Fixes #178 Coven-Harness: Codex / GPT-6 --- scripts/tui-tests/cases/03_command_palette.sh | 5 +++-- src-rust/crates/cli/src/main.rs | 4 ++-- src-rust/crates/tui/src/app.rs | 5 +++-- src-rust/crates/tui/src/render.rs | 16 ++++++++++++++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/tui-tests/cases/03_command_palette.sh b/scripts/tui-tests/cases/03_command_palette.sh index 6353592b..9c53fc3a 100644 --- a/scripts/tui-tests/cases/03_command_palette.sh +++ b/scripts/tui-tests/cases/03_command_palette.sh @@ -24,8 +24,9 @@ tc_palette() { # 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" diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 2230038e..1e173857 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2311,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 @@ -2906,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(), ); } diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 0ef52ae3..bf38edee 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -4033,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(); diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 8837d1aa..efd8292f 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -2352,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), + ), ]) };