Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,512 @@
//! Wrapper that turns an ACP `AvailableCommand` into a `SlashCommand`.
//!
//! ACP-advertised commands appear in the dropdown but pass through to the
//! shell for execution. The wrapper stores `String` fields -- consistent
//! with the `&str` trait design.
//!
//! Skill commands (those with `meta.path` + `meta.scope`) are handled
//! client-side: pager reads the SKILL.md, applies substitutions, and
//! sends structured prompt blocks directly. Non-skill ACP commands
//! pass through to the shell as before.
use agent_client_protocol as acp;
use xai_grok_tools::implementations::skills::types::SkillScope;
use super::command::{CommandExecCtx, CommandResult, SlashCommand};
/// A slash command backed by an ACP `AvailableCommand`.
///
/// For skill commands (has `skill_path` + `skill_scope`), execution reads
/// the SKILL.md client-side and produces `CommandResult::InjectSkill`.
/// For non-skill commands, execution produces `CommandResult::PassThrough`.
pub struct AcpSlashCommand {
name: String,
description: String,
has_args: bool,
arg_hint: Option<String>,
/// Skill-specific: path to SKILL.md on disk. None for shell builtins.
skill_path: Option<String>,
/// Skill-specific: parsed scope enum. None for shell builtins.
skill_scope: Option<SkillScope>,
/// True if the ACP meta had skill-like keys but they were invalid.
meta_malformed: bool,
}
impl SlashCommand for AcpSlashCommand {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn usage(&self) -> &str {
&self.name
}
fn takes_args(&self) -> bool {
self.has_args
}
/// ACP commands always accept Enter -- args are never required locally.
/// The shell validates.
fn args_required(&self) -> bool {
false
}
fn arg_placeholder(&self) -> Option<&str> {
self.arg_hint.as_deref()
}
fn is_skill(&self) -> bool {
self.skill_path.is_some() && self.skill_scope.is_some()
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
// Malformed skill metadata — surface error, don't silently degrade.
if self.meta_malformed {
return CommandResult::Error(format!("Malformed skill metadata for /{}", self.name));
}
// Non-skill ACP commands: pass through to the shell as before.
if self.skill_path.is_none() || self.skill_scope.is_none() {
let text = if args.trim().is_empty() {
format!("/{}", self.name)
} else {
format!("/{} {}", self.name, args)
};
return CommandResult::PassThrough(text);
}
// --- Pass skill through to the shell for expansion ---
//
// The shell's slash_commands::resolve() handles skill detection,
// SKILL.md loading, substitution, and assembly of the
// <user_query> + <skill_information> format. The pager just
// sends the raw `/skill args` text as a single prompt block.
let display_text = if args.trim().is_empty() {
format!("/{}", self.name)
} else {
format!("/{} {}", self.name, args)
};
let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
display_text.clone(),
))];
CommandResult::InjectSkill {
display_text,
prompt_blocks,
display_as_skill: true,
scheduled_task_preview: None,
}
}
}
impl From<&acp::AvailableCommand> for AcpSlashCommand {
fn from(cmd: &acp::AvailableCommand) -> Self {
let arg_hint = cmd.input.as_ref().and_then(|input| match input {
acp::AvailableCommandInput::Unstructured(u) => Some(u.hint.clone()),
// TODO(acp-0.10): `AvailableCommandInput` is #[non_exhaustive].
_ => None,
});
// Parse skill metadata from ACP `_meta`:
// { "scope": "local", "path": "/path/to/SKILL.md" }
//
// Missing meta → non-skill ACP command (PassThrough).
// Present but malformed meta → meta_malformed = true (Error on run()).
let (skill_path, skill_scope, meta_malformed) = match cmd.meta.as_ref() {
None => (None, None, false),
Some(m) => {
let path = m
.get("path")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let scope: Option<SkillScope> = m
.get("scope")
.and_then(|v| serde_json::from_value(v.clone()).ok());
if path.is_some() && scope.is_some() {
(path, scope, false)
} else if m.get("path").is_some() || m.get("scope").is_some() {
// Has skill-like keys but they're invalid
(None, None, true)
} else {
// Meta exists but has no skill keys (e.g., other metadata)
(None, None, false)
}
}
};
Self {
name: cmd.name.clone(),
description: cmd.description.clone(),
// ACP commands always accept free-form input. The shell handles
// whatever text follows the command name. The `input` field only
// determines the placeholder hint, not whether args are allowed.
has_args: true,
arg_hint,
skill_path,
skill_scope,
meta_malformed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cmd(name: &str, meta: Option<serde_json::Value>) -> acp::AvailableCommand {
let mut cmd = acp::AvailableCommand::new(name.to_string(), format!("{name} command"));
if let Some(m) = meta.and_then(|v| v.as_object().cloned()) {
cmd = cmd.meta(m);
}
cmd
}
#[test]
fn no_meta_is_non_skill() {
let cmd = make_cmd("flush", None);
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(acp_cmd.skill_path.is_none());
assert!(acp_cmd.skill_scope.is_none());
assert!(!acp_cmd.meta_malformed);
}
#[test]
fn valid_skill_meta_populates_fields() {
let meta = serde_json::json!({
"scope": "local",
"path": "/home/user/.grok/skills/commit/SKILL.md"
});
let cmd = make_cmd("commit", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert_eq!(
acp_cmd.skill_path.as_deref(),
Some("/home/user/.grok/skills/commit/SKILL.md")
);
assert_eq!(acp_cmd.skill_scope, Some(SkillScope::Local));
assert!(!acp_cmd.meta_malformed);
}
#[test]
fn invalid_scope_value_is_malformed() {
let meta = serde_json::json!({
"scope": "invalid_scope",
"path": "/path/to/SKILL.md"
});
let cmd = make_cmd("broken", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(acp_cmd.skill_path.is_none());
assert!(acp_cmd.skill_scope.is_none());
assert!(acp_cmd.meta_malformed);
}
#[test]
fn path_not_string_is_malformed() {
let meta = serde_json::json!({
"scope": "local",
"path": 42
});
let cmd = make_cmd("broken", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(acp_cmd.meta_malformed);
}
#[test]
fn scope_only_no_path_is_malformed() {
let meta = serde_json::json!({
"scope": "user"
});
let cmd = make_cmd("partial", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(acp_cmd.meta_malformed);
}
#[test]
fn path_only_no_scope_is_malformed() {
let meta = serde_json::json!({
"path": "/path/to/SKILL.md"
});
let cmd = make_cmd("partial", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(acp_cmd.meta_malformed);
}
#[test]
fn unrelated_meta_is_non_skill() {
let meta = serde_json::json!({
"foo": "bar",
"baz": 42
});
let cmd = make_cmd("other", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(acp_cmd.skill_path.is_none());
assert!(acp_cmd.skill_scope.is_none());
assert!(!acp_cmd.meta_malformed);
}
#[test]
fn all_scope_variants_parse_correctly() {
for (scope_str, expected) in [
("local", SkillScope::Local),
("repo", SkillScope::Repo),
("user", SkillScope::User),
("plugin", SkillScope::Plugin),
] {
let meta = serde_json::json!({
"scope": scope_str,
"path": "/path/to/SKILL.md"
});
let cmd = make_cmd("test", Some(meta));
let acp_cmd = AcpSlashCommand::from(&cmd);
assert_eq!(acp_cmd.skill_scope, Some(expected), "scope={scope_str}");
assert!(!acp_cmd.meta_malformed);
}
}
// -- run() tests --
fn make_skill_cmd(name: &str, path: &str, scope: &str) -> AcpSlashCommand {
AcpSlashCommand {
name: name.to_string(),
description: format!("{name} skill"),
has_args: true,
arg_hint: None,
skill_path: Some(path.to_string()),
skill_scope: serde_json::from_value(serde_json::json!(scope)).ok(),
meta_malformed: false,
}
}
fn make_exec_ctx() -> CommandExecCtx<'static> {
use crate::acp::model_state::ModelState;
let models = Box::leak(Box::new(ModelState::default()));
let bundle = Box::leak(Box::new(crate::app::bundle::BundleState::default()));
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn run_non_skill_passes_through() {
let cmd = AcpSlashCommand {
name: "flush".to_string(),
description: "flush".to_string(),
has_args: true,
arg_hint: None,
skill_path: None,
skill_scope: None,
meta_malformed: false,
};
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::PassThrough(t) if t == "/flush"));
}
#[test]
fn run_malformed_meta_returns_error() {
let cmd = AcpSlashCommand {
name: "broken".to_string(),
description: "broken".to_string(),
has_args: true,
arg_hint: None,
skill_path: None,
skill_scope: None,
meta_malformed: true,
};
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Error(msg) if msg.contains("Malformed")));
}
#[test]
fn run_missing_file_passes_through_to_shell() {
// The pager no longer reads SKILL.md — it passes through to the shell.
// A missing file still produces InjectSkill with the raw `/skill args` text.
let cmd = make_skill_cmd("commit", "/nonexistent/path/SKILL.md", "local");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "fix bug");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/commit fix bug");
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/commit fix bug");
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
#[test]
fn run_skill_produces_inject_skill() {
// The pager sends raw `/skill args` text — the shell handles expansion.
let cmd = make_skill_cmd("commit", "/some/path/SKILL.md", "local");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "fix the auth bug");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/commit fix the auth bug");
// Single block with the raw `/skill args` text.
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/commit fix the auth bug");
// No XML markup.
assert!(!text.contains("<command-name>"));
assert!(!text.contains("<skill"));
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
#[test]
fn run_skill_no_args_omits_command_args_tag() {
let cmd = make_skill_cmd("deploy", "/some/path/SKILL.md", "user");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/deploy");
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/deploy");
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
#[test]
fn run_skill_qualified_name_not_double_prefixed() {
// Shell advertises "local:commit" when there's a duplicate bare name.
let cmd = make_skill_cmd("local:commit", "/some/path/SKILL.md", "local");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "fix bug");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/local:commit fix bug");
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/local:commit fix bug");
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
#[test]
fn run_skill_user_qualified_name_preserved() {
// Shell advertises "user:commit" for a user-scoped skill that collides.
let cmd = make_skill_cmd("user:commit", "/some/path/SKILL.md", "user");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/user:commit");
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/user:commit");
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
#[test]
fn run_skill_builtin_colliding_name_preserved() {
// Shell advertises "local:compact" when a skill collides with the
// built-in /compact command.
let cmd = make_skill_cmd("local:compact", "/some/path/SKILL.md", "local");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/local:compact");
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/local:compact");
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
#[test]
fn run_skill_substitutes_skill_dir() {
// The pager no longer does substitutions — it passes through to the shell.
// This test verifies the pass-through behavior.
let cmd = make_skill_cmd("config", "/some/path/SKILL.md", "local");
let mut ctx = make_exec_ctx();
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
..
} => {
assert_eq!(display_text, "/config");
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
other => panic!("expected Text, got {:?}", other),
};
assert_eq!(text, "/config");
}
other => panic!("expected InjectSkill, got {:?}", other),
}
}
}

View file

@ -0,0 +1,306 @@
//! Slash command trait and execution types.
//!
//! Pager's synchronous dispatch model. Key differences:
//!
//! - `run()` is synchronous (no `async_trait`). Commands that need async work
//! return `CommandResult::Action(action)` and let the dispatch layer handle it.
//! - `CommandResult` has additional variants: `Action`, `QueueCommand`, `PassThrough`.
//! - Trait methods return `&str` (not `&'static str`) to support ACP-sourced commands.
//! - `args_required()` added for the two-bit completeness model.
//! - `validate_args()` intentionally omitted in phase 1 (folded into `run()`).
use crate::acp::model_state::ModelState;
use crate::app::actions::Action;
use crate::app::bundle::BundleState;
use agent_client_protocol as acp;
/// Provisional scheduled task info for immediate display in the tasks pane.
///
/// Created by `/loop` when the user submits the command so the task appears
/// instantly, rather than waiting for the LLM round-trip through
/// `scheduler_create`.
#[derive(Debug, Clone)]
pub struct ScheduledTaskPreview {
pub prompt: String,
pub human_schedule: String,
pub next_fire_at: Option<String>,
/// Tag shown in the tasks pane (e.g. "loop", "check"). Defaults to "loop".
pub tag: String,
}
/// Result of running a slash command.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum CommandResult {
/// Command handled successfully, no visible output needed.
/// Included for TUI parity; no phase-1 command uses this directly.
Handled,
/// Command handled but was a no-op (e.g., model already selected).
/// Included for TUI parity. Dispatch treats it identically to Handled.
HandledNoOp,
/// Command failed with an error message.
Error(String),
/// Command produced a user-visible message.
Message(String),
/// Command produced a pager Action to dispatch (e.g., SwitchModel, Quit).
Action(Action),
/// Command should be sent through the queued command pipeline
/// (e.g., /compact). The String is the raw command text.
QueueCommand(String),
/// Skill invocation: pager read the SKILL.md, applied substitutions,
/// and constructed structured prompt blocks for the wire.
/// `display_text` is what the user sees in scrollback.
/// `prompt_blocks` is the actual content sent to the model.
InjectSkill {
display_text: String,
prompt_blocks: Vec<agent_client_protocol::ContentBlock>,
/// Whether to display as a skill invocation (teal accent) in scrollback.
/// `true` for real skills (e.g. /commit), `false` for built-in commands
/// like /loop that inject structured prompts but aren't skills.
display_as_skill: bool,
/// If set, immediately show a provisional scheduled task in the tasks
/// pane (replaced when the real `ScheduledTaskCreated` notification
/// arrives from the shell).
scheduled_task_preview: Option<ScheduledTaskPreview>,
},
/// Command text should be sent as a regular prompt. The shell resolves it.
///
/// Phase-1 simplification: this intentionally covers two semantically
/// different cases in a single variant:
/// 1. ACP-advertised commands (shell explicitly supports them)
/// 2. Unknown commands (pager doesn't know them, shell might)
///
/// Both are sent identically today. If behavior ever needs to diverge
/// (e.g., different UX confidence, error messaging, or telemetry),
/// split into `AcpPassThrough` and `UnknownPassThrough` variants.
PassThrough(String),
}
/// A suggestion item for command argument completion.
#[derive(Debug, Clone)]
pub struct ArgItem {
/// Display text shown in the dropdown.
pub display: String,
/// Text used for fuzzy matching.
pub match_text: String,
/// Text inserted into the prompt on acceptance.
pub insert_text: String,
/// Description shown alongside the item.
pub description: String,
}
/// Read-only context for generating suggestions.
///
/// Passed to `SlashCommand::suggest_args()` and `SlashCommand::visible()`.
/// Kept minimal -- extend as needed.
pub struct AppCtx<'a> {
pub models: &'a ModelState,
/// Working directory of the active session (for filesystem completions).
pub cwd: &'a std::path::Path,
/// Session announcements (critical or promo) exist (gates `/announcements` visibility).
pub has_session_announcements: bool,
/// Effective render mode of this process (gates `/minimal` and
/// `/fullscreen` visibility). Same source of truth as
/// [`CommandExecCtx::screen_mode`], carried by the owning
/// [`SlashController`](crate::slash::SlashController).
pub(crate) screen_mode: crate::app::ScreenMode,
}
/// Mutable execution context for `SlashCommand::run()`.
///
/// Wraps only what pager can cleanly provide. Commands that need async ACP
/// calls return `CommandResult::Action(...)` and let dispatch handle the effect.
pub struct CommandExecCtx<'a> {
pub models: &'a ModelState,
pub session_id: Option<&'a acp::SessionId>,
pub bundle_state: &'a BundleState,
pub(crate) screen_mode: crate::app::ScreenMode,
/// Snapshot of the active agent's PAGER-owned settings, built at
/// command-build time by the dispatcher. Slash commands like
/// `/multiline` read this to compute `!current` and dispatch a
/// typed `Action::SetX(new)` — the dispatcher remains the single
/// source of truth for the actual state mutation.
pub(crate) pager_state: crate::settings::PagerLocalSnapshot,
}
/// A slash command.
///
/// Implementors define command metadata (name, description, args) and
/// synchronous execution logic. The trait uses `&str` returns (not
/// `&'static str`) so ACP-sourced commands with runtime-determined data
/// work from day one.
pub trait SlashCommand: Send + Sync {
/// Canonical command name (without leading `/`). E.g., `"exit"`.
fn name(&self) -> &str;
/// Alternative names for this command. E.g., `&["quit"]` for `/exit`.
fn aliases(&self) -> &[&str] {
&[]
}
/// Short human-readable description shown in the dropdown.
fn description(&self) -> &str;
/// Usage string shown in help. E.g., `"/model <name>"`.
fn usage(&self) -> &str;
/// Whether the command accepts arguments at all.
fn takes_args(&self) -> bool {
false
}
/// Whether arguments are required for execution.
///
/// Only meaningful when `takes_args()` is true. The two-bit model:
///
/// | `takes_args` | `args_required` | Example | Enter with no args |
/// |-------------|----------------|------------------|-------------------|
/// | `false` | `false` | `/exit` | Executes |
/// | `true` | `false` | `/compact [ctx]` | Executes |
/// | `true` | `true` | `/model <id>` | Blocks |
fn args_required(&self) -> bool {
false
}
/// Generate argument suggestions. `args_query` is the raw typed
/// args text; most impls ignore it and return a static list.
#[allow(unused_variables)]
fn suggest_args(&self, ctx: &AppCtx, args_query: &str) -> Option<Vec<ArgItem>> {
None
}
/// Whether this command is currently visible / executable.
///
/// Default is `true` (every command is visible). Override to gate a
/// command on session state.
#[allow(unused_variables)]
fn visible(&self, ctx: &AppCtx) -> bool {
true
}
/// Whether this command operates on a single agent session — its
/// conversation, context, model, turns, plan, etc. — rather than the
/// pager as a whole.
///
/// Session-scoped commands (`/compact`, `/fork`, `/rewind`, …) need a
/// "current session" to act on, so they are suppressed on session-less
/// surfaces. Today that means the agent dashboard's dispatch input,
/// which offers only pager-global commands (`/theme`, `/settings`,
/// `/mcps`, …). Surfaces that always have a session (the agent view)
/// ignore this flag and continue to show every command.
///
/// Defaults to `false` (pager-global).
fn session_scoped(&self) -> bool {
false
}
/// Whether a `session_scoped()` command should still be offered on
/// session-less surfaces (the agent dashboard's dispatch input).
///
/// A handful of session-scoped commands have a meaningful session-less
/// interpretation: `/model` and `/plan` configure the *next* agent the
/// dashboard spawns; `/multiline` toggles compose mode on the dashboard
/// inputs. Those override this to `true` so they appear in the dashboard
/// dropdown even though `session_scoped()` is `true`. Has no effect for
/// non-session-scoped commands (they're always offered).
///
/// Defaults to `false`.
fn offered_when_session_less(&self) -> bool {
false
}
/// Whether this command should ONLY be offered on the session-less
/// dashboard surface — the inverse of [`Self::session_scoped`]. The
/// dashboard's dispatch input is the one surface where
/// `hide_session_scoped` is set, so a `dashboard_only` command shows
/// there and is suppressed on every session surface (the agent view) and
/// the welcome screen.
///
/// `/cd` changes where the dashboard dispatches new agents, so it is
/// meaningless in an agent session and hidden there. Defaults to `false`.
fn dashboard_only(&self) -> bool {
false
}
/// Whether this command functions in the scrollback-native **minimal**
/// mode (`grok --minimal`).
///
/// Minimal mode deletes the interactive fullscreen scrollback pane, the
/// in-app mouse selection/copy path, and the agent dashboard, handing
/// scroll / search / selection back to the terminal (K7). Commands that
/// drive those deleted surfaces — `/find`, `/copy`, `/dashboard` — have
/// nothing to act on, so the central dispatch gate refuses them with a
/// "/<x> is not available in minimal mode" message (committed as a system
/// block).
///
/// Defaults to `true` — a **denylist, not an allowlist**: the many
/// mode-agnostic commands keep working and new commands are available in
/// minimal by default (the mode is converging toward parity). Override to
/// `false` only for genuinely fullscreen-pane-dependent commands.
fn available_in_minimal(&self) -> bool {
true
}
/// Placeholder text shown in the prompt when args are empty.
/// E.g., `"[context]"` for `/compact`.
fn arg_placeholder(&self) -> Option<&str> {
None
}
/// Whether this command is a skill (ACP-advertised with skill metadata).
/// Used for visual theming (accent color, prefix glyph).
fn is_skill(&self) -> bool {
false
}
/// Tool names the agent must have registered for this command to work.
///
/// Default is empty (no tool dependency). Override for commands that
/// only make sense when specific tools are available -- e.g. `/loop`
/// requires `scheduler_create`. The registry hides commands whose
/// requirements aren't all present in the agent's advertised toolset.
fn required_tools(&self) -> &[&str] {
&[]
}
/// Whether this command supports live preview when navigating arg
/// suggestions in the dropdown.
///
/// When true, [`preview_arg`] is called on every selection change
/// and [`cancel_preview`] on dropdown close (Esc).
fn supports_preview(&self) -> bool {
false
}
/// Capture the current preview-relevant state as a string.
///
/// Called once when preview mode begins (first navigation in args
/// dropdown). The returned value is stored and passed back to
/// [`cancel_preview`] if the user dismisses the dropdown.
fn preview_state(&self) -> Option<String> {
None
}
/// Live-preview the given argument suggestion.
///
/// Called when the user navigates to a new suggestion in the dropdown
/// (Up/Down). The command should apply a temporary/preview state.
/// Only called when [`supports_preview`] returns true.
#[allow(unused_variables)]
fn preview_arg(&self, arg: &str) {}
/// Cancel a live preview, reverting to the state before the dropdown
/// opened. `previous` is the value returned by [`preview_state`]
/// when preview started.
///
/// Called when the user dismisses the dropdown (Esc) or clears the
/// slash input. Only called when [`supports_preview`] returns true.
#[allow(unused_variables)]
fn cancel_preview(&self, previous: &str) {}
/// Execute the command synchronously.
///
/// For async work, return `CommandResult::Action(action)` and let
/// the dispatch layer handle the effect pipeline.
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult;
}

View file

@ -0,0 +1,92 @@
//! `/always-approve` -- toggle auto-approve (YOLO / `permission_mode`).
//!
//! Dispatches `Action::SetYoloMode(!current)`. The dispatcher handles
//! state mutation, permission_queue drain, persistence (with rollback
//! on disk-write failure), and toast.
//!
//! No scrollback turn — visible effects are the prompt-line chip and
//! a toast (destructive-styled when enabling).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Toggle always-approve (YOLO / `permission_mode`).
pub struct AlwaysApproveCommand;
impl SlashCommand for AlwaysApproveCommand {
fn name(&self) -> &str {
"always-approve"
}
fn description(&self) -> &str {
"Toggle always-approve mode (skip all permission prompts)"
}
fn usage(&self) -> &str {
"/always-approve"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let new = !ctx.pager_state.yolo_mode;
CommandResult::Action(Action::SetYoloMode(new))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
fn make_ctx<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
yolo_mode: bool,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot {
multiline_mode: false,
yolo_mode,
..PagerLocalSnapshot::default()
},
}
}
#[test]
fn off_turns_always_approve_on() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false);
assert!(matches!(
AlwaysApproveCommand.run(&mut ctx, ""),
CommandResult::Action(Action::SetYoloMode(true))
));
}
#[test]
fn on_turns_always_approve_off() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, true);
assert!(matches!(
AlwaysApproveCommand.run(&mut ctx, ""),
CommandResult::Action(Action::SetYoloMode(false))
));
}
#[test]
fn ignores_args() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false);
assert!(matches!(
AlwaysApproveCommand.run(&mut ctx, "extra"),
CommandResult::Action(Action::SetYoloMode(true))
));
}
}

View file

@ -0,0 +1,160 @@
//! `/announcements` -- show or hide the announcement banner.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
const USAGE: &str = "Usage: /announcements hide | show";
/// Control the announcement banner (hide/show).
pub struct AnnouncementsCommand;
impl SlashCommand for AnnouncementsCommand {
fn name(&self) -> &str {
"announcements"
}
fn description(&self) -> &str {
"Show or hide announcements"
}
fn usage(&self) -> &str {
"/announcements hide | show"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("hide|show")
}
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
Some(vec![
ArgItem {
display: "hide".to_string(),
match_text: "hide".to_string(),
insert_text: "hide".to_string(),
description: "Hide the announcement banner".to_string(),
},
ArgItem {
display: "show".to_string(),
match_text: "show".to_string(),
insert_text: "show".to_string(),
description: "Show the announcement banner".to_string(),
},
])
}
fn visible(&self, ctx: &AppCtx) -> bool {
ctx.has_session_announcements
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
match args.split_whitespace().next().unwrap_or("") {
"hide" => CommandResult::Action(Action::AnnouncementsHide),
"show" => CommandResult::Action(Action::AnnouncementsShow),
_ => CommandResult::Error(USAGE.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::slash::command::CommandExecCtx;
fn run(args: &str) -> CommandResult {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
AnnouncementsCommand.run(&mut ctx, args)
}
#[test]
fn run_subcommands() {
assert!(matches!(
run("hide"),
CommandResult::Action(Action::AnnouncementsHide)
));
assert!(matches!(
run("show"),
CommandResult::Action(Action::AnnouncementsShow)
));
assert!(matches!(
run(" hide "),
CommandResult::Action(Action::AnnouncementsHide)
));
assert!(matches!(
run("hide extra"),
CommandResult::Action(Action::AnnouncementsHide)
));
}
#[test]
fn run_invalid_or_empty_shows_usage() {
for args in ["", "foo", "next", "prev"] {
match run(args) {
CommandResult::Error(msg) => assert!(msg.contains("/announcements")),
other => panic!("expected Error for {args:?}, got {other:?}"),
}
}
}
#[test]
fn suggest_args_lists_subcommands() {
let models = ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = AnnouncementsCommand
.suggest_args(&ctx, "")
.expect("suggestions");
let names: Vec<_> = items.iter().map(|i| i.insert_text.as_str()).collect();
assert_eq!(names, ["hide", "show"]);
}
#[test]
fn visible_only_with_session_announcements() {
let models = ModelState::default();
let cmd = AnnouncementsCommand;
// Flag is independent of the per-ID hidden set — true means menu
// still offers /announcements after hide (so show remains discoverable).
assert!(!cmd.visible(&AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
}));
assert!(cmd.visible(&AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
}));
}
#[test]
fn metadata() {
let cmd = AnnouncementsCommand;
assert_eq!(cmd.name(), "announcements");
assert!(cmd.takes_args());
assert!(cmd.args_required());
assert_eq!(cmd.arg_placeholder(), Some("hide|show"));
}
}

View file

@ -0,0 +1,114 @@
//! `/auto` -- toggle auto permission mode (LLM classifier).
//!
//! - Off (or always-approve) → `SetPermissionMode(Auto)`
//! - Already auto → `SetPermissionMode(Ask)` (toggle off)
//!
//! The dispatcher owns state mutation, persistence (with rollback), and toast.
//! Visibility is gated by
//! [`crate::slash::SlashController::set_auto_mode_available`]: `/auto` is
//! hard-hidden when the auto permission-mode feature is off.
use crate::app::actions::{Action, PermissionModeKind};
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Toggle auto permission mode (LLM classifier).
pub struct AutoCommand;
impl SlashCommand for AutoCommand {
fn name(&self) -> &str {
"auto"
}
fn description(&self) -> &str {
"Toggle auto mode (classifier approves safe tools)"
}
fn usage(&self) -> &str {
"/auto"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
// Yolo wins over auto: if always-approve is on, treat auto as off so
// `/auto` switches into auto rather than "toggling off" to ask.
let currently_auto = ctx.pager_state.auto_mode && !ctx.pager_state.yolo_mode;
let kind = if currently_auto {
PermissionModeKind::Ask
} else {
PermissionModeKind::Auto
};
CommandResult::Action(Action::SetPermissionMode(kind))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
fn make_ctx<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
yolo_mode: bool,
auto_mode: bool,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot {
yolo_mode,
auto_mode,
auto_mode_gate: true,
..PagerLocalSnapshot::default()
},
}
}
#[test]
fn off_turns_auto_on() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false, false);
assert!(matches!(
AutoCommand.run(&mut ctx, ""),
CommandResult::Action(Action::SetPermissionMode(PermissionModeKind::Auto))
));
}
#[test]
fn on_turns_auto_off() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false, true);
assert!(matches!(
AutoCommand.run(&mut ctx, ""),
CommandResult::Action(Action::SetPermissionMode(PermissionModeKind::Ask))
));
}
#[test]
fn always_approve_switches_to_auto() {
let models = ModelState::default();
let bundle = BundleState::default();
// Stale auto_mode=true with yolo on must still switch to Auto.
let mut ctx = make_ctx(&models, &bundle, true, true);
assert!(matches!(
AutoCommand.run(&mut ctx, ""),
CommandResult::Action(Action::SetPermissionMode(PermissionModeKind::Auto))
));
}
#[test]
fn ignores_args() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false, false);
assert!(matches!(
AutoCommand.run(&mut ctx, "extra"),
CommandResult::Action(Action::SetPermissionMode(PermissionModeKind::Auto))
));
}
}

View file

@ -0,0 +1,43 @@
//! `/btw` -- ask a side question without interrupting the running agent.
//!
//! Returns `CommandResult::Action(Action::SendBtw(...))` so the dispatch layer
//! fires it as an ACP ext method (`x.ai/btw`) that bypasses the prompt queue.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct BtwCommand;
impl SlashCommand for BtwCommand {
fn name(&self) -> &str {
"btw"
}
fn description(&self) -> &str {
"Ask a side question without interrupting"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/btw <question>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("<question>")
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
CommandResult::Action(Action::SendBtw(args.trim().to_string()))
}
}

View file

@ -0,0 +1,122 @@
//! `/cd [path]` — change the working directory new dashboard sessions
//! spawn in.
//!
//! With no argument it opens the dashboard's location picker; with a path
//! it changes directly. Both are dashboard affordances — invoked from a
//! non-dashboard surface the dispatcher prints a toast pointing the user
//! at `/dashboard` (see `dispatch_dashboard_open_location_picker` /
//! `dispatch_dashboard_change_location`).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Change the working directory for newly dispatched dashboard sessions.
pub struct CdCommand;
impl SlashCommand for CdCommand {
fn name(&self) -> &str {
"cd"
}
fn description(&self) -> &str {
"Change the working directory for new agents"
}
fn usage(&self) -> &str {
"/cd [path]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("path")
}
/// `/cd` only makes sense on the dashboard (it changes where the
/// dashboard dispatches new agents), so hide it from completion on
/// every other surface — the agent view and the welcome screen.
fn dashboard_only(&self) -> bool {
true
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if trimmed.is_empty() {
CommandResult::Action(Action::DashboardOpenLocationPicker)
} else {
CommandResult::Action(Action::DashboardChangeLocation {
input: trimmed.to_string(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
/// Build a throwaway exec ctx over the given borrows. Mirrors the
/// inline ctx construction in `dashboard.rs`'s command tests.
fn ctx<'a>(models: &'a ModelState, bundle: &'a BundleState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn no_args_opens_location_picker() {
let (models, bundle) = (ModelState::default(), BundleState::default());
let mut c = ctx(&models, &bundle);
assert!(matches!(
CdCommand.run(&mut c, ""),
CommandResult::Action(Action::DashboardOpenLocationPicker)
));
}
#[test]
fn whitespace_only_opens_location_picker() {
let (models, bundle) = (ModelState::default(), BundleState::default());
let mut c = ctx(&models, &bundle);
assert!(matches!(
CdCommand.run(&mut c, " "),
CommandResult::Action(Action::DashboardOpenLocationPicker)
));
}
#[test]
fn path_arg_changes_location() {
let (models, bundle) = (ModelState::default(), BundleState::default());
let mut c = ctx(&models, &bundle);
match CdCommand.run(&mut c, " ~/projects/foo ") {
CommandResult::Action(Action::DashboardChangeLocation { input }) => {
assert_eq!(input, "~/projects/foo");
}
other => panic!("expected DashboardChangeLocation, got {other:?}"),
}
}
#[test]
fn metadata() {
let cmd = CdCommand;
assert_eq!(cmd.name(), "cd");
assert!(cmd.takes_args());
assert_eq!(cmd.arg_placeholder(), Some("path"));
assert!(!cmd.description().is_empty());
assert!(!cmd.usage().is_empty());
// `/cd` is dashboard-only — hidden from completion on every other
// surface (the agent view, the welcome screen).
assert!(cmd.dashboard_only(), "/cd must be dashboard-only");
}
}

View file

@ -0,0 +1,51 @@
//! `/compact` -- compact conversation history.
//!
//! Takes an optional context argument. Stays on the existing queue pipeline:
//! returns `CommandResult::QueueCommand` so the dispatch layer enqueues it
//! as `QueueEntryKind::Command`.
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Compact the conversation history, optionally with a focus context.
pub struct CompactCommand;
impl SlashCommand for CompactCommand {
fn name(&self) -> &str {
"compact"
}
fn description(&self) -> &str {
"Compact conversation history"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/compact compaction instructions"
}
fn takes_args(&self) -> bool {
true
}
/// Args are optional -- `/compact` with no args is valid.
fn args_required(&self) -> bool {
false
}
fn arg_placeholder(&self) -> Option<&str> {
Some("compaction instructions")
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
// Re-emit as queue command, preserving the full text.
let text = if args.trim().is_empty() {
"/compact".to_string()
} else {
format!("/compact {}", args)
};
CommandResult::QueueCommand(text)
}
}

View file

@ -0,0 +1,31 @@
//! `/compact-mode` -- toggle compact display mode.
//!
//! Reduces user message padding by disabling vertical padding on prompt blocks.
//!
//! Dispatches `Action::ToggleCompactMode` so the slash command and the
//! keybinding share one toggle gate (which reads the USER value — the render
//! value may be auto-forced on short terminals).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Toggle compact display mode via `/compact-mode`.
pub struct CompactModeCommand;
impl SlashCommand for CompactModeCommand {
fn name(&self) -> &str {
"compact-mode"
}
fn description(&self) -> &str {
"Toggle compact UI (less padding, more content)"
}
fn usage(&self) -> &str {
"/compact-mode"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::ToggleCompactMode)
}
}

View file

@ -0,0 +1,29 @@
//! `/config-agents` -- open the agents modal.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open the agents modal listing all agent definitions.
pub struct ConfigAgentsCommand;
impl SlashCommand for ConfigAgentsCommand {
fn name(&self) -> &str {
"config-agents"
}
fn aliases(&self) -> &[&str] {
&["agents"]
}
fn description(&self) -> &str {
"Manage agent definitions"
}
fn usage(&self) -> &str {
"/config-agents"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenConfigAgentsModal(None))
}
}

View file

@ -0,0 +1,33 @@
//! `/context` -- show detailed context usage (instant, not queued).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show context usage breakdown (progress bar, token categories, stats).
pub struct ContextCommand;
impl SlashCommand for ContextCommand {
fn name(&self) -> &str {
"context"
}
fn description(&self) -> &str {
"View context usage"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/context"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_string());
}
CommandResult::Action(Action::ShowContextInfo)
}
}

View file

@ -0,0 +1,159 @@
//! `/copy` -- copy the last (or Nth) assistant message to the clipboard.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Copy an assistant message to the clipboard.
pub struct CopyCommand;
impl SlashCommand for CopyCommand {
fn name(&self) -> &str {
"copy"
}
fn description(&self) -> &str {
"Copy last response to clipboard (/copy N for Nth-latest)"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/copy [N]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[N]")
}
/// Minimal mode has no in-app copy path — native terminal selection
/// replaces it (K7/§6.13). Gated off with a message.
fn available_in_minimal(&self) -> bool {
false
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
let n = if trimmed.is_empty() {
1
} else {
match trimmed.parse::<usize>() {
Ok(0) => {
return CommandResult::Error(
"Usage: /copy [N] where N is 1 (latest), 2, 3, ...".to_string(),
);
}
Ok(v) => v,
Err(_) => {
return CommandResult::Error(format!(
"/copy {trimmed} (invalid number)\nUsage: /copy [N] where N is 1 (latest), 2, 3, ..."
));
}
}
};
CommandResult::Action(Action::CopyAssistantMessage { n })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::actions::Action;
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx(models: &ModelState) -> CommandExecCtx<'_> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}
#[test]
fn no_args_copies_latest() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = CopyCommand;
match cmd.run(&mut ctx, "") {
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
}
}
#[test]
fn explicit_1_copies_latest() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = CopyCommand;
match cmd.run(&mut ctx, "1") {
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
}
}
#[test]
fn explicit_3_copies_third() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = CopyCommand;
match cmd.run(&mut ctx, "3") {
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 3),
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
}
}
#[test]
fn zero_returns_error() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = CopyCommand;
assert!(matches!(cmd.run(&mut ctx, "0"), CommandResult::Error(_)));
}
#[test]
fn non_numeric_returns_error() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = CopyCommand;
match cmd.run(&mut ctx, "abc") {
CommandResult::Error(msg) => assert!(msg.contains("invalid number")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn whitespace_only_copies_latest() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = CopyCommand;
match cmd.run(&mut ctx, " ") {
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
}
}
#[test]
fn not_available_in_minimal() {
// Native terminal selection replaces the in-app copy path in minimal.
assert!(!CopyCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,147 @@
//! `/dashboard` — open the Agent Dashboard view.
//!
//! Centralised overview of every running session (top-level + subagents)
//! with peek, attach, and dispatch from one screen. The dashboard reuses
//! the existing fullscreen subagent takeover for "attach to subagent",
//! so attaching never bypasses `active_subagent`.
//!
//! Same `Action`-only run path as other session-less commands, no args.
//! `/sessions` is an alias (see [`SlashCommand::aliases`]): the dashboard
//! replaced the removed sessions picker modal for switching, renaming, and
//! closing active sessions. Visibility is feature-flag-gated: the
//! command is hidden by default in the registry and revealed when the
//! dashboard feature is enabled (`dashboard_enabled()`), via
//! [`crate::app::agent_view::AgentView::set_dashboard_visible`]. When
//! `[dashboard].enabled = false` or `GROK_AGENT_DASHBOARD=0` is set, the
//! dispatcher prints a friendly toast and refuses to open. The dashboard is
//! independent of leader mode.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand};
/// Open the Agent Dashboard view.
pub struct DashboardCommand;
impl SlashCommand for DashboardCommand {
fn name(&self) -> &str {
"dashboard"
}
/// `/agents-dashboard` is registered as an alias. The canonical
/// name remains `/dashboard`.
///
/// `/sessions` survives the sessions-modal removal as an alias: the
/// dashboard is the replacement surface for switching, renaming, and
/// closing active sessions, so old muscle memory redirects here. As an
/// alias it inherits the feature-flag gate (`set_dashboard_visible`
/// hides by canonical name) and the minimal-mode gates below.
fn aliases(&self) -> &[&str] {
&["agents-dashboard", "sessions"]
}
fn description(&self) -> &str {
"Open the Agent Dashboard — a fullscreen overview of every running session"
}
fn usage(&self) -> &str {
"/dashboard"
}
/// The agent dashboard is intentionally out of scope in minimal mode
/// (single-session standalone — K14/§6.15). Gated off with a message.
fn available_in_minimal(&self) -> bool {
false
}
/// Hidden from the completion dropdown in minimal mode: the dashboard
/// (and its `/sessions` / `/agents-dashboard` spellings) has nothing to
/// open there, so offering it just to refuse at dispatch is noise. A
/// fully-typed invocation still resolves and hits the central
/// `available_in_minimal` dispatch gate (friendly refusal, fail-closed).
fn visible(&self, ctx: &AppCtx) -> bool {
!ctx.screen_mode.is_minimal()
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenDashboard)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult};
#[test]
fn run_returns_open_dashboard_action() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
let cmd = DashboardCommand;
assert!(matches!(
cmd.run(&mut ctx, ""),
CommandResult::Action(Action::OpenDashboard)
));
}
/// Feature-flag gating is applied externally by the registry
/// (`set_dashboard_visible`), not via `visible()` — `AppCtx` carries no
/// dashboard state. `visible()` only gates on screen mode: offered in
/// fullscreen/inline, hidden from the minimal-mode dropdown (where the
/// dashboard has nothing to open and dispatch would just refuse).
#[test]
fn visible_everywhere_except_minimal() {
let models = ModelState::default();
let cmd = DashboardCommand;
let ctx = |screen_mode| AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode,
};
assert!(cmd.visible(&ctx(crate::app::ScreenMode::Fullscreen)));
assert!(cmd.visible(&ctx(crate::app::ScreenMode::Inline)));
assert!(
!cmd.visible(&ctx(crate::app::ScreenMode::Minimal)),
"the dashboard (and its /sessions alias) must not be offered in minimal mode"
);
}
#[test]
fn does_not_take_args() {
let cmd = DashboardCommand;
assert!(!cmd.takes_args());
}
#[test]
fn name_is_dashboard() {
let cmd = DashboardCommand;
assert_eq!(cmd.name(), "dashboard");
}
/// `/sessions` (removed picker modal) and `/agents-dashboard`
/// both spell this command.
#[test]
fn aliases_include_sessions() {
let cmd = DashboardCommand;
assert_eq!(cmd.aliases(), &["agents-dashboard", "sessions"]);
}
#[test]
fn not_available_in_minimal() {
// The dashboard is out of scope in scrollback-native minimal mode.
assert!(!DashboardCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,197 @@
//! `/debug` — debug-overlay toggles (scroll HUD, FPS HUD, scroll log).
//!
//! Registration/visibility split: the command is registered on EVERY binary
//! and fully functional in release — like the hidden diagnostics it fronts
//! (`/scroll-debug`, `/gboom`) — but it is LISTED (dropdown, completion,
//! recognized-token highlight via `visible()`) only on debug binaries
//! (`cfg(debug_assertions)`). Discoverable where developers live, out of
//! sight for users, yet still typeable in the field when support asks.
//!
//! Subcommands (args-based; a popup menu can come later):
//! - `/debug` bare — print the toggles and their state to the transcript.
//! - `/debug scroll` — the scroll-diagnostics HUD; same
//! [`Action::ToggleScrollDebugHud`] as `/scroll-debug`, which stays
//! registered as the hidden long-form alias.
//! - `/debug fps` — the release-safe FPS HUD
//! ([`crate::views::fps_hud`]).
//! - `/debug log` — the scroll flight recorder
//! ([`crate::input::scroll_log`]), runtime-constructed to a fresh
//! timestamped path.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
/// Whether `/debug` is listed on completion surfaces. `visible()` returns
/// this constant, so release invisibility is pinned by the constant's shape
/// (`cfg!(debug_assertions)`) rather than a runtime check — tests always
/// compile with `debug_assertions`, so the release half is untestable by
/// assertion and locked by construction instead.
pub const LISTED_IN_COMPLETIONS: bool = cfg!(debug_assertions);
/// Subcommand name/description pairs (single source for run + suggestions).
const SUBCOMMANDS: &[(&str, &str)] = &[
("scroll", "Toggle the scroll-diagnostics HUD"),
("fps", "Toggle the FPS overlay"),
("log", "Toggle the scroll flight recorder (JSONL)"),
];
/// Debug-overlay toggles; listed only on debug binaries.
pub struct DebugCommand;
impl SlashCommand for DebugCommand {
fn name(&self) -> &str {
"debug"
}
fn description(&self) -> &str {
"Toggle debug overlays"
}
fn usage(&self) -> &str {
"/debug [scroll|fps|log]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("scroll | fps | log")
}
/// Debug binaries only; release keeps it registered but unlisted.
fn visible(&self, _ctx: &AppCtx) -> bool {
LISTED_IN_COMPLETIONS
}
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
Some(
SUBCOMMANDS
.iter()
.map(|&(name, desc)| ArgItem {
display: name.to_string(),
match_text: name.to_string(),
insert_text: name.to_string(),
description: desc.to_string(),
})
.collect(),
)
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
match args.trim() {
// Bare: the cheapest useful menu — a status line in scrollback.
"" => CommandResult::Action(Action::ShowDebugStatus),
"scroll" => CommandResult::Action(Action::ToggleScrollDebugHud),
"fps" => CommandResult::Action(Action::ToggleFpsHud),
"log" => CommandResult::Action(Action::ToggleScrollLog),
other => CommandResult::Error(format!(
"Unknown /debug option '{other}'. Usage: /debug [scroll|fps|log]"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::slash::commands::scroll_debug::ScrollDebugCommand;
use crate::slash::commands::tests::make_ctx;
fn app_ctx(models: &ModelState) -> AppCtx<'_> {
AppCtx {
models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
}
}
/// Tests compile with `debug_assertions`, so this asserts the
/// debug-binary half live: `/debug` must be visible here. The release
/// half (invisible) is untestable from a debug test build and pinned by
/// mechanism instead — `visible()` returns `LISTED_IN_COMPLETIONS =
/// cfg!(debug_assertions)`, which a release compile evaluates to
/// `false` by construction; the `assert_eq!` locks `visible()` to that
/// constant under whichever profile compiles the test.
#[test]
fn debug_listed_on_debug_binaries_only() {
let models = ModelState::default();
let listed = DebugCommand.visible(&app_ctx(&models));
assert_eq!(
listed,
cfg!(debug_assertions),
"visible() must track the binary profile"
);
assert_eq!(listed, LISTED_IN_COMPLETIONS);
}
/// `/debug scroll` and `/scroll-debug` must stay routed to the SAME
/// action — the HUD has one toggle, two spellings.
#[test]
fn debug_scroll_routes_to_same_action_as_scroll_debug() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
assert!(matches!(
DebugCommand.run(&mut ctx, "scroll"),
CommandResult::Action(Action::ToggleScrollDebugHud)
));
assert!(matches!(
ScrollDebugCommand.run(&mut ctx, ""),
CommandResult::Action(Action::ToggleScrollDebugHud)
));
}
#[test]
fn debug_bare_emits_status() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
for args in ["", " "] {
assert!(matches!(
DebugCommand.run(&mut ctx, args),
CommandResult::Action(Action::ShowDebugStatus)
));
}
}
#[test]
fn debug_fps_and_log_route_to_their_toggles() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
assert!(matches!(
DebugCommand.run(&mut ctx, " fps "),
CommandResult::Action(Action::ToggleFpsHud)
));
assert!(matches!(
DebugCommand.run(&mut ctx, "log"),
CommandResult::Action(Action::ToggleScrollLog)
));
}
#[test]
fn debug_junk_subcommand_errors_helpfully() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
match DebugCommand.run(&mut ctx, "wat") {
CommandResult::Error(msg) => {
assert!(msg.contains("wat"), "must echo the bad option: {msg}");
assert!(
msg.contains("scroll") && msg.contains("fps") && msg.contains("log"),
"must list the valid options: {msg}"
);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn debug_suggest_args_lists_subcommands() {
let models = ModelState::default();
let items = DebugCommand
.suggest_args(&app_ctx(&models), "")
.expect("suggestions");
let names: Vec<&str> = items.iter().map(|i| i.insert_text.as_str()).collect();
assert_eq!(names, vec!["scroll", "fps", "log"]);
}
}

View file

@ -0,0 +1,221 @@
//! `/docs` -- open How-to Guides (in-TUI) or online Build docs.
//!
//! Bare `/docs` opens the same DocPicker as command-palette "How-to Guides".
//! `/docs web` opens https://docs.x.ai/build/overview in the browser.
//! `/docs <title>` opens a single guide by title (case-insensitive).
use crate::app::actions::Action;
use crate::docs::{all_titles, find_doc};
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
/// Online Build docs landing page (hardcoded like other TUI deep-links; docs.x.ai can redirect if the path moves).
pub const BUILD_DOCS_URL: &str = "https://docs.x.ai/build/overview";
/// Open How-to Guides or online Build docs.
pub struct DocsCommand;
impl SlashCommand for DocsCommand {
fn name(&self) -> &str {
"docs"
}
fn aliases(&self) -> &[&str] {
&["howto", "guides"]
}
fn description(&self) -> &str {
"Open How-to Guides or online Build docs"
}
fn usage(&self) -> &str {
"/docs [web|title]"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
false
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[web|title]")
}
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
let mut items = vec![
ArgItem {
display: "how-to".into(),
match_text: "how-to".into(),
insert_text: "how-to".into(),
description: "Browse in-TUI How-to Guides".into(),
},
ArgItem {
display: "web".into(),
match_text: "web".into(),
insert_text: "web".into(),
description: "Open docs.x.ai/build in the browser".into(),
},
];
items.extend(all_titles().map(|title| ArgItem {
display: title.into(),
match_text: title.into(),
insert_text: title.into(),
description: format!("Open \"{title}\""),
}));
Some(items)
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if trimmed.is_empty() || is_howto_list_arg(trimmed) {
return CommandResult::Action(Action::OpenHowtoGuides);
}
if is_web_arg(trimmed) {
return CommandResult::Action(Action::OpenUrl(BUILD_DOCS_URL.into()));
}
match find_doc(trimmed) {
Some(doc) => CommandResult::Action(Action::ShowReleaseNotes {
title: doc.title.into(),
content: doc.content.into(),
}),
None => CommandResult::Error(format!(
"Unknown docs target {trimmed:?}. Try /docs, /docs web, or a guide title (e.g. /docs Getting Started)."
)),
}
}
}
fn is_howto_list_arg(arg: &str) -> bool {
matches!(
arg.to_ascii_lowercase().as_str(),
"how-to" | "howto" | "guides" | "guide" | "list" | "tui"
)
}
fn is_web_arg(arg: &str) -> bool {
matches!(
arg.to_ascii_lowercase().as_str(),
"web" | "online" | "browser" | "site" | "www"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx<'a>(models: &'a ModelState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn bare_docs_opens_howto_guides() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
assert!(matches!(
DocsCommand.run(&mut ctx, ""),
CommandResult::Action(Action::OpenHowtoGuides)
));
}
#[test]
fn howto_aliases_open_list() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
for args in ["how-to", "howto", "guides", "list", "tui"] {
assert!(
matches!(
DocsCommand.run(&mut ctx, args),
CommandResult::Action(Action::OpenHowtoGuides)
),
"args={args:?}"
);
}
}
#[test]
fn web_opens_build_docs_url() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
for args in ["web", "online", "browser"] {
match DocsCommand.run(&mut ctx, args) {
CommandResult::Action(Action::OpenUrl(url)) => {
assert_eq!(url, BUILD_DOCS_URL, "args={args:?}");
}
other => panic!("expected OpenUrl for args={args:?}, got {other:?}"),
}
}
}
#[test]
fn title_opens_guide() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
match DocsCommand.run(&mut ctx, "Getting Started") {
CommandResult::Action(Action::ShowReleaseNotes { title, content }) => {
assert_eq!(title, "Getting Started");
assert!(!content.is_empty());
}
other => panic!("expected ShowReleaseNotes, got {other:?}"),
}
}
#[test]
fn unknown_target_errors() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
assert!(matches!(
DocsCommand.run(&mut ctx, "not-a-real-guide"),
CommandResult::Error(_)
));
}
#[test]
fn aliases_and_metadata() {
let cmd = DocsCommand;
assert_eq!(cmd.name(), "docs");
assert_eq!(cmd.aliases(), &["howto", "guides"]);
assert!(cmd.takes_args());
assert!(!cmd.args_required());
}
#[test]
fn suggest_args_includes_web_and_titles() {
let models = ModelState::default();
let cwd = std::path::Path::new(".");
let ctx = AppCtx {
models: &models,
cwd,
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = DocsCommand.suggest_args(&ctx, "").expect("suggestions");
assert!(items.iter().any(|i| i.insert_text == "web"));
assert!(items.iter().any(|i| i.insert_text == "how-to"));
assert!(items.iter().any(|i| i.insert_text == "Getting Started"));
}
}

View file

@ -0,0 +1,366 @@
//! `/effort` — set reasoning effort on the current model without re-picking it.
//!
//! Thin wrapper over `Action::SwitchModel` with the session's current model
//! id and the chosen effort (same wire path as `/model <name> <effort>`).
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
use crate::slash::commands::effort_levels::build_effort_arg_items;
/// Set reasoning effort for the active model.
pub struct EffortCommand;
impl SlashCommand for EffortCommand {
fn name(&self) -> &str {
"effort"
}
fn description(&self) -> &str {
"Set reasoning effort for the current model"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
// Levels are model-specific; empty-args and UnknownToken errors list
// the active model's offered option ids instead of a hardcoded set.
"/effort <level>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("<level>")
}
fn suggest_args(&self, ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
let options = ctx.models.reasoning_effort_options();
if options.is_empty() {
return None;
}
Some(build_effort_arg_items(
&options,
ctx.models.reasoning_effort,
true,
|option| option.id.clone(),
))
}
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
let Some(model_id) = ctx.models.current.clone() else {
return CommandResult::Error("No active model".into());
};
if trimmed.is_empty() {
let offered: Vec<String> = ctx
.models
.reasoning_effort_options_for(&model_id)
.into_iter()
.map(|opt| opt.id)
.collect();
let current = ctx
.models
.reasoning_effort
.map(|e| format!(" (current: {e})"))
.unwrap_or_default();
let levels = if offered.is_empty() {
"<level>".to_string()
} else {
offered.join("|")
};
return CommandResult::Error(format!("Usage: /effort <{levels}>{current}"));
}
// Same gate-first policy as the CLI (`--effort`) and headless.
match ctx.models.resolve_effort_for_model(&model_id, trimmed) {
Ok(effort) => CommandResult::Action(Action::SwitchModel {
model_id,
effort: Some(effort),
}),
Err(err) => CommandResult::Error(err.message()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::slash::commands::effort_levels::EFFORT_LEVELS;
use agent_client_protocol as acp;
use std::sync::Arc;
use xai_grok_shell::sampling::types::ReasoningEffort;
fn model_with_reasoning(id: &str, name: &str) -> (acp::ModelId, acp::ModelInfo) {
let id = acp::ModelId::new(Arc::from(id));
let mut meta = serde_json::Map::new();
meta.insert(
"supportsReasoningEffort".into(),
serde_json::Value::Bool(true),
);
let info = acp::ModelInfo::new(id.clone(), name.to_string())
.meta(serde_json::Value::Object(meta).as_object().cloned());
(id, info)
}
fn plain_model(id: &str, name: &str) -> (acp::ModelId, acp::ModelInfo) {
let id = acp::ModelId::new(Arc::from(id));
let info = acp::ModelInfo::new(id.clone(), name.to_string());
(id, info)
}
static EMPTY_BUNDLE: crate::app::bundle::BundleState = crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn dummy_exec_ctx(models: &ModelState) -> CommandExecCtx<'_> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &EMPTY_BUNDLE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn empty_args_errors_with_usage() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id.clone(), info);
state.current = Some(id);
state.reasoning_effort = Some(ReasoningEffort::Medium);
let mut ctx = dummy_exec_ctx(&state);
let result = EffortCommand.run(&mut ctx, "");
match result {
CommandResult::Error(msg) => {
assert!(msg.contains("Usage: /effort"));
// Legacy menu option ids only — not none/minimal.
assert!(msg.contains("xhigh|high|medium|low"), "msg={msg}");
assert!(msg.contains("current: medium"));
assert!(!msg.contains("none"));
assert!(!msg.contains("minimal"));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn unknown_level_errors() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id.clone(), info);
state.current = Some(id);
let mut ctx = dummy_exec_ctx(&state);
let result = EffortCommand.run(&mut ctx, "turbo");
match result {
CommandResult::Error(msg) => {
assert!(msg.contains("unknown effort level 'turbo'"), "msg={msg}");
assert!(msg.contains("use one of:"), "msg={msg}");
assert!(msg.contains("xhigh"), "msg={msg}");
assert!(!msg.contains("none"), "msg={msg}");
assert!(!msg.contains("minimal"), "msg={msg}");
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn valid_level_dispatches_switch_model_on_current() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id.clone(), info);
state.current = Some(id.clone());
let mut ctx = dummy_exec_ctx(&state);
let result = EffortCommand.run(&mut ctx, "high");
match result {
CommandResult::Action(Action::SwitchModel { model_id, effort }) => {
assert_eq!(model_id, id);
assert_eq!(effort, Some(ReasoningEffort::High));
}
other => panic!("expected SwitchModel with effort, got {other:?}"),
}
}
#[test]
fn none_and_minimal_rejected_when_model_menu_omits_them() {
// Legacy fallback menu is low..xhigh — `none`/`minimal` used to pass
// through and 400 on grok-4.5; reject at the TUI instead.
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id.clone(), info);
state.current = Some(id);
let mut ctx = dummy_exec_ctx(&state);
for token in ["none", "minimal"] {
let result = EffortCommand.run(&mut ctx, token);
match result {
CommandResult::Error(ref msg) => {
assert!(
msg.contains(&format!("unknown effort level '{token}'")),
"expected Error for {token}, got {msg}"
);
// Must not re-advertise the rejected token as a valid choice
// (aside from quoting it in "unknown effort level '…'").
let after_prefix = msg
.split_once("; ")
.map(|(_, rest)| rest)
.unwrap_or(msg.as_str());
assert!(
!after_prefix.contains(token),
"error must not list {token} as offered: {msg}"
);
assert!(!msg.contains("unset"), "msg={msg}");
}
other => panic!("expected Error for {token}, got {other:?}"),
}
}
}
#[test]
fn none_accepted_when_model_menu_offers_it() {
let mut state = ModelState::default();
let id = acp::ModelId::new(Arc::from("voice-dual"));
let info = acp::ModelInfo::new(id.clone(), "Voice Dual".to_string()).meta(
serde_json::json!({
"supportsReasoningEffort": true,
"reasoningEfforts": [
{ "value": "none", "label": "None", "default": true },
{ "value": "high", "label": "High" },
],
})
.as_object()
.cloned(),
);
state.available.insert(id.clone(), info);
state.current = Some(id.clone());
let mut ctx = dummy_exec_ctx(&state);
let result = EffortCommand.run(&mut ctx, "none");
match result {
CommandResult::Action(Action::SwitchModel { model_id, effort }) => {
assert_eq!(model_id, id);
assert_eq!(effort, Some(ReasoningEffort::None));
}
other => panic!("expected SwitchModel with none, got {other:?}"),
}
}
#[test]
fn remap_id_dispatches_mapped_canonical_effort() {
let mut state = ModelState::default();
let id = acp::ModelId::new(Arc::from("reasoning-x"));
let info = acp::ModelInfo::new(id.clone(), "Reasoning X".to_string()).meta(
serde_json::json!({
"supportsReasoningEffort": true,
"reasoningEfforts": [{ "id": "deep", "value": "xhigh", "label": "Deep" }],
})
.as_object()
.cloned(),
);
state.available.insert(id.clone(), info);
state.current = Some(id.clone());
let mut ctx = dummy_exec_ctx(&state);
// The rendered row inserts the id; `/effort deep` must send `xhigh`.
match EffortCommand.run(&mut ctx, "deep") {
CommandResult::Action(Action::SwitchModel { model_id, effort }) => {
assert_eq!(model_id, id);
assert_eq!(effort, Some(ReasoningEffort::Xhigh));
}
other => panic!("expected SwitchModel with remapped effort, got {other:?}"),
}
}
#[test]
fn non_reasoning_model_errors() {
let mut state = ModelState::default();
let (id, info) = plain_model("grok-4.5", "Grok 4.5");
state.available.insert(id.clone(), info);
state.current = Some(id);
let mut ctx = dummy_exec_ctx(&state);
let result = EffortCommand.run(&mut ctx, "high");
assert!(matches!(
result,
CommandResult::Error(msg) if msg.contains("does not support reasoning effort")
));
}
#[test]
fn no_current_model_errors() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id, info);
let mut ctx = dummy_exec_ctx(&state);
let result = EffortCommand.run(&mut ctx, "high");
assert!(matches!(result, CommandResult::Error(msg) if msg.contains("No active model")));
}
#[test]
fn suggest_args_none_without_current_or_support() {
let cmd = EffortCommand;
let empty = ModelState::default();
let ctx = AppCtx {
models: &empty,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(cmd.suggest_args(&ctx, "").is_none());
let mut plain = ModelState::default();
let (id, info) = plain_model("grok-4.5", "Grok 4.5");
plain.available.insert(id.clone(), info);
plain.current = Some(id);
let ctx = AppCtx {
models: &plain,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(cmd.suggest_args(&ctx, "").is_none());
}
#[test]
fn suggest_args_lists_levels_with_active_marker() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id.clone(), info);
state.current = Some(id);
state.reasoning_effort = Some(ReasoningEffort::High);
let cmd = EffortCommand;
let ctx = AppCtx {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").unwrap();
assert_eq!(items.len(), EFFORT_LEVELS.len());
assert_eq!(items[0].insert_text, "xhigh");
assert_eq!(items[1].insert_text, "high");
assert_eq!(items[1].display, "high (active)");
assert_eq!(items[2].insert_text, "medium");
assert_eq!(items[3].insert_text, "low");
assert!(items[0].match_text.starts_with("a "));
assert!(items[3].match_text.starts_with("d "));
}
}

View file

@ -0,0 +1,77 @@
//! Shared reasoning-effort dropdown levels for `/model` and `/effort`.
use xai_grok_shell::sampling::types::{ReasoningEffort, ReasoningEffortOption};
use crate::slash::command::ArgItem;
/// Effort levels in the built-in fallback menu (strongest first). `none`/`minimal`
/// are still accepted by `ReasoningEffort::from_str` for power users.
pub(crate) const EFFORT_LEVELS: &[ReasoningEffort] = &[
ReasoningEffort::Xhigh,
ReasoningEffort::High,
ReasoningEffort::Medium,
ReasoningEffort::Low,
];
pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str {
match level {
ReasoningEffort::None => "No reasoning",
ReasoningEffort::Minimal => "Minimal reasoning",
ReasoningEffort::Low => "Faster, lighter reasoning",
ReasoningEffort::Medium => "Balanced reasoning",
ReasoningEffort::High => "Heavy reasoning",
ReasoningEffort::Xhigh => "Maximum reasoning",
}
}
/// The built-in menu used when the server sends no `reasoningEfforts`. Reproduces
/// the historical rows: labels are the lowercase level (via `Display`),
/// descriptions from `effort_description`. The active row is matched by value
/// against the session effort at render time, so `default` is left unset here.
pub(crate) fn legacy_effort_options() -> Vec<ReasoningEffortOption> {
EFFORT_LEVELS
.iter()
.map(|&level| ReasoningEffortOption {
id: level.as_str().to_string(),
value: level,
label: level.to_string(),
description: Some(effort_description(level).to_string()),
default: false,
})
.collect()
}
/// Build effort rows for autocomplete from a per-model option list.
///
/// - `mark_active` + `current_effort` mark the current session effort with `(active)`.
/// - `insert_text_for` controls what is inserted on select:
/// - `/effort`: the option id (`"deep"`)
/// - `/model` chained phase: `"ModelName deep"`
///
/// `match_text` gets an `a `/`b `/…` sort prefix so the matcher's alphabetical
/// tiebreak preserves the option order.
pub(crate) fn build_effort_arg_items(
options: &[ReasoningEffortOption],
current_effort: Option<ReasoningEffort>,
mark_active: bool,
insert_text_for: impl Fn(&ReasoningEffortOption) -> String,
) -> Vec<ArgItem> {
options
.iter()
.enumerate()
.map(|(idx, option)| {
let active = mark_active && current_effort == Some(option.value);
let active_suffix = if active { " (active)" } else { "" };
let insert_text = insert_text_for(option);
// Sort-key prefix: 'a' for top row, 'b' for next, etc. Only
// affects matcher tiebreak ordering, never rendered.
let sort_prefix = char::from(b'a' + idx as u8);
ArgItem {
display: format!("{}{active_suffix}", option.label),
match_text: format!("{sort_prefix} {insert_text}"),
insert_text,
description: option.description.clone().unwrap_or_default(),
}
})
.collect()
}

View file

@ -0,0 +1,29 @@
//! `/quit` (alias `/exit`) -- quit the application.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Quit the pager application.
pub struct ExitCommand;
impl SlashCommand for ExitCommand {
fn name(&self) -> &str {
"quit"
}
fn aliases(&self) -> &[&str] {
&["exit"]
}
fn description(&self) -> &str {
"Quit the application"
}
fn usage(&self) -> &str {
"/quit"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::Quit)
}
}

View file

@ -0,0 +1,113 @@
//! `/expand` -- re-print the last collapsed block, fully expanded (minimal mode).
//!
//! In the scrollback-native minimal mode (`grok --minimal`) finalized blocks are
//! printed once into the terminal's native scrollback, with reasoning collapsed
//! and large tool output truncated (design decision K9). Committed terminal text
//! can't be mutated, so "expanding" one is an honest re-print of the same block
//! in full below the conversation (K10). `/expand` is the slash-command twin of
//! the `Ctrl+E` chord; both walk backwards through the most-recently committed
//! folded blocks.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Re-print the last collapsed/truncated block, fully expanded (minimal mode).
pub struct ExpandCommand;
impl SlashCommand for ExpandCommand {
fn name(&self) -> &str {
"expand"
}
fn description(&self) -> &str {
"Re-print the last collapsed block, fully expanded (minimal mode)"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/expand"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
// Expansion is meaningful only in minimal mode — the full-TUI scrollback
// pane folds/unfolds blocks in place (the `e` / `Ctrl+E` chords) and has
// no print-once committed history to re-print.
if !ctx.screen_mode.is_minimal() {
return CommandResult::Message(
"/expand is only available in minimal mode (--minimal)".to_string(),
);
}
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_string());
}
CommandResult::Action(Action::MinimalExpandLast)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn ctx<'a>(
models: &'a ModelState,
session_id: Option<&'a agent_client_protocol::SessionId>,
screen_mode: crate::app::ScreenMode,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode,
pager_state: PagerLocalSnapshot::default(),
}
}
#[test]
fn minimal_with_session_dispatches_expand_action() {
let models = ModelState::default();
let sid = agent_client_protocol::SessionId::from("s1".to_string());
let mut c = ctx(&models, Some(&sid), crate::app::ScreenMode::Minimal);
assert!(matches!(
ExpandCommand.run(&mut c, ""),
CommandResult::Action(Action::MinimalExpandLast)
));
}
#[test]
fn non_minimal_returns_message() {
let models = ModelState::default();
let sid = agent_client_protocol::SessionId::from("s1".to_string());
let mut c = ctx(&models, Some(&sid), crate::app::ScreenMode::Fullscreen);
match ExpandCommand.run(&mut c, "") {
CommandResult::Message(msg) => assert!(msg.contains("minimal")),
other => panic!("expected Message, got {other:?}"),
}
}
#[test]
fn minimal_without_session_errors() {
let models = ModelState::default();
let mut c = ctx(&models, None, crate::app::ScreenMode::Minimal);
match ExpandCommand.run(&mut c, "") {
CommandResult::Error(msg) => assert!(msg.contains("No active session")),
other => panic!("expected Error, got {other:?}"),
}
}
}

View file

@ -0,0 +1,242 @@
//! `/export [filename]` -- export the current conversation transcript as Markdown.
//!
//! Omit the filename (or pass empty) to copy the full transcript to the clipboard.
//! With a filename, writes a UTF-8 .md file (supports ~ expansion, paths with spaces,
//! and parent directory creation).
//!
//! Pager-side only (local TUI execution). Follows the exact patterns from
//! `copy.rs`, `share.rs`, and the SlashCommand trait in `command.rs`.
use std::path::{Path, PathBuf};
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
/// Export the current conversation to a file or clipboard.
pub struct ExportCommand;
impl SlashCommand for ExportCommand {
fn name(&self) -> &str {
"export"
}
fn description(&self) -> &str {
"Export the current conversation to a file or clipboard"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/export [filename]"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
false
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[filename]")
}
fn suggest_args(&self, ctx: &AppCtx, args_query: &str) -> Option<Vec<ArgItem>> {
let items = list_path_completions(ctx.cwd, args_query);
if items.is_empty() { None } else { Some(items) }
}
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session to export".to_string());
}
let trimmed = args.trim();
let file_path: Option<PathBuf> = if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
};
CommandResult::Action(Action::ExportConversation { file_path })
}
}
/// List filesystem entries for path completion in the `/export` args dropdown.
///
/// Parses the typed query to extract a directory prefix, lists its contents,
/// and returns `ArgItem`s. Directories get a trailing `/` in `insert_text` so
/// the dropdown stays open for drill-down (same trick `/model` uses with
/// trailing space for effort chaining).
///
/// The `SlashController` handles nucleo fuzzy ranking on the returned items
/// automatically — we just provide the candidates.
///
/// Synchronous `read_dir` — same pattern as `/model` and `/theme` which query
/// `ModelState` synchronously. Local directory listing is sub-millisecond;
/// the 1000-entry pre-sort cap guards against pathological directories.
/// Moving to the `@`-style background daemon would require adding tick-based
/// polling to the slash command system (which is currently event-driven only).
fn list_path_completions(cwd: &Path, query: &str) -> Vec<ArgItem> {
let trimmed = query.trim_start();
if trimmed.is_empty() {
return Vec::new();
}
let input_path = PathBuf::from(shellexpand::tilde(trimmed).as_ref());
// Determine which directory to list and what prefix the user has typed.
// If the input ends with `/`, list that directory's contents.
// Otherwise, list the parent and let nucleo filter by the partial filename.
let (dir_to_list, typed_prefix) = if trimmed.ends_with('/') {
(input_path.clone(), trimmed.to_string())
} else {
let parent = input_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(cwd);
// Reconstruct the user's prefix up to the last `/` (preserving ~).
let prefix = match trimmed.rfind('/') {
Some(pos) => &trimmed[..=pos],
None => "",
};
(parent.to_path_buf(), prefix.to_string())
};
// Resolve relative paths against cwd.
let resolved = if dir_to_list.is_relative() {
cwd.join(&dir_to_list)
} else {
dir_to_list
};
let entries = match std::fs::read_dir(&resolved) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut items: Vec<ArgItem> = Vec::new();
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
// Follow symlinks so symlinked directories get trailing `/`.
let is_dir = entry.path().is_dir();
let suffix = if is_dir { "/" } else { "" };
items.push(ArgItem {
display: format!("{name_str}{suffix}"),
match_text: format!("{typed_prefix}{name_str}"),
insert_text: format!("{typed_prefix}{name_str}{suffix}"),
description: if is_dir {
"directory".to_string()
} else {
"file".to_string()
},
});
// Pre-sort cap to avoid pathological directories.
if items.len() >= 1000 {
break;
}
}
// Sort: directories first, then alphabetical. Truncate after sort.
items.sort_by(|a, b| {
let a_dir = a.display.ends_with('/');
let b_dir = b.display.ends_with('/');
b_dir.cmp(&a_dir).then_with(|| a.display.cmp(&b.display))
});
items.truncate(100);
items
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::actions::Action;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx(models: &ModelState) -> CommandExecCtx<'_> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot::default(),
}
}
#[test]
fn no_session_errors() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = ExportCommand;
match cmd.run(&mut ctx, "") {
CommandResult::Error(msg) => assert!(msg.contains("No active session")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn dispatches_clipboard_when_no_path() {
let models = ModelState::default();
let sid = agent_client_protocol::SessionId::from("test-session".to_string());
let mut ctx = CommandExecCtx {
models: &models,
session_id: Some(&sid),
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot::default(),
};
let cmd = ExportCommand;
match cmd.run(&mut ctx, " ") {
CommandResult::Action(Action::ExportConversation { file_path }) => {
assert!(file_path.is_none());
}
other => panic!("expected ExportConversation(None), got {other:?}"),
}
}
#[test]
fn dispatches_file_path_when_given() {
let models = ModelState::default();
let sid = agent_client_protocol::SessionId::from("s2".to_string());
let mut ctx = CommandExecCtx {
models: &models,
session_id: Some(&sid),
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot::default(),
};
let cmd = ExportCommand;
match cmd.run(&mut ctx, "~/exports/my convo with spaces.md") {
CommandResult::Action(Action::ExportConversation { file_path }) => {
let p = file_path.expect("some path");
assert!(p.to_string_lossy().contains("my convo with spaces.md"));
}
other => panic!("expected ExportConversation(Some), got {other:?}"),
}
}
}

View file

@ -0,0 +1,38 @@
//! `/feedback` -- send session feedback.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Send session feedback inline or enter feedback mode.
pub struct FeedbackCommand;
impl SlashCommand for FeedbackCommand {
fn name(&self) -> &str {
"feedback"
}
fn description(&self) -> &str {
"Send feedback about the current session"
}
fn usage(&self) -> &str {
"/feedback [text]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[feedback text]")
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if trimmed.is_empty() {
CommandResult::Action(Action::EnterFeedbackMode)
} else {
CommandResult::Action(Action::SendFeedback(trimmed.to_string()))
}
}
}

View file

@ -0,0 +1,132 @@
//! `/find` -- open an incremental search over the conversation scrollback.
//!
//! In simple mode a bare `/` goes to the prompt, so simple-mode users can't
//! reach the vim `/` scrollback search. `/find` focuses the scrollback pane
//! and opens the same search from either mode.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open scrollback search via `/find`.
pub struct FindCommand;
impl SlashCommand for FindCommand {
fn name(&self) -> &str {
"find"
}
fn description(&self) -> &str {
"Search the conversation scrollback"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/find [text]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[text]")
}
/// Minimal mode has no interactive scrollback pane to search — the
/// terminal's own search covers it (K7/§6.13). Gated off with a message.
fn available_in_minimal(&self) -> bool {
false
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
// whitespace-only args open a blank search.
let initial = args.trim();
let query = (!initial.is_empty()).then(|| initial.to_string());
CommandResult::Action(Action::OpenScrollbackSearch(query))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx(models: &ModelState) -> CommandExecCtx<'_> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}
#[test]
fn find_returns_open_scrollback_search_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = FindCommand;
assert!(matches!(
cmd.run(&mut ctx, ""),
CommandResult::Action(Action::OpenScrollbackSearch(None))
));
}
#[test]
fn find_with_word_carries_it_as_initial_query() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = FindCommand;
let CommandResult::Action(Action::OpenScrollbackSearch(query)) = cmd.run(&mut ctx, "foo")
else {
panic!("/find foo must open scrollback search");
};
assert_eq!(query.as_deref(), Some("foo"));
}
#[test]
fn find_with_blank_args_carries_no_initial_query() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = FindCommand;
// Both a bare `/find` and whitespace-only args open a blank search.
for args in ["", " "] {
assert!(matches!(
cmd.run(&mut ctx, args),
CommandResult::Action(Action::OpenScrollbackSearch(None))
));
}
}
#[test]
fn find_advertises_optional_text_arg() {
// Pins the slash-arg contract so completion-accept appends a trailing
// space and the `[text]` placeholder shows while typing; bare `/find`
// stays valid (args not required).
let cmd = FindCommand;
assert!(cmd.takes_args());
assert!(!cmd.args_required());
assert_eq!(cmd.arg_placeholder(), Some("[text]"));
}
#[test]
fn not_available_in_minimal() {
// Native terminal search replaces in-app scrollback search in minimal.
assert!(!FindCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,337 @@
//! `/fork` -- branch the current session into a peer top-level agent.
//!
//! The command parses optional flags (`--worktree`, `--no-worktree`) and
//! an optional free-form directive, then returns
//! [`Action::Fork`](crate::app::actions::Action::Fork) carrying a
//! [`ForkArgs`] payload. The actual placeholder construction, modal
//! routing, and effect emission live in `dispatch::dispatch_fork`.
//!
//! The actual fork dispatch happens in `dispatch_fork_resolved` (after
//! the worktree question is resolved and the placeholder spawn succeeds),
//! not in this command itself.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Parsed arguments for the `/fork` slash command.
///
/// Returned by [`parse_fork_args`] and carried in
/// [`Action::Fork`](crate::app::actions::Action::Fork) for the
/// dispatcher to consume.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ForkArgs {
/// `None` -> open the worktree question modal (the user is
/// asked every time; the choice is never persisted).
/// `Some(true)` -> force worktree, skipping the modal.
/// `Some(false)` -> force no-worktree, skipping the modal.
pub worktree_override: Option<bool>,
/// Optional first prompt for the new session. Whitespace-trimmed.
/// `None` when the user invoked `/fork` (with or without flags) but
/// no directive text. The new agent simply opens with no first prompt.
pub directive: Option<String>,
}
/// Parse the raw argument string after `/fork`.
///
/// Recognised flags appear at the start; everything after the last flag
/// is the directive. Unknown flags are deliberately treated as the
/// start of the directive (so `/fork --foo bar` becomes a directive
/// `--foo bar`) -- the parser is conservative because the args are
/// user-typed text and we do not want to reject directives that happen
/// to begin with `--`.
///
/// Errors:
/// - `--worktree` and `--no-worktree` cannot both appear.
/// - `--at <turn>` returns a friendly "not supported in this version"
/// message: the shell already supports the underlying parameter (see
/// `xai_grok_shell::session::fork::ForkSessionRequest::target_prompt_index`)
/// and a turn-picker UI is planned; this version deliberately rejects
/// the flag so users discover the deferral cleanly.
pub fn parse_fork_args(args: &str) -> Result<ForkArgs, String> {
let mut worktree_override: Option<bool> = None;
let mut rest = args.trim_start();
while !rest.is_empty() {
let (flag, after) = match rest.split_once(char::is_whitespace) {
Some(parts) => parts,
None => (rest, ""),
};
match flag {
"--worktree" => {
if worktree_override == Some(false) {
return Err("--worktree and --no-worktree are mutually exclusive".into());
}
if worktree_override == Some(true) {
return Err("--worktree specified twice".into());
}
worktree_override = Some(true);
rest = after.trim_start();
}
"--no-worktree" => {
if worktree_override == Some(true) {
return Err("--worktree and --no-worktree are mutually exclusive".into());
}
if worktree_override == Some(false) {
return Err("--no-worktree specified twice".into());
}
worktree_override = Some(false);
rest = after.trim_start();
}
"--at" => {
return Err("--at is not supported in this version".into());
}
_ => break,
}
}
let directive = if rest.is_empty() {
None
} else {
Some(rest.to_string())
};
Ok(ForkArgs {
worktree_override,
directive,
})
}
/// `/fork` slash command implementation.
pub struct ForkCommand;
impl SlashCommand for ForkCommand {
fn name(&self) -> &str {
"fork"
}
fn description(&self) -> &str {
"Branch the current session into a peer agent"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/fork [--worktree|--no-worktree] [directive]"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
false
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[directive]")
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
match parse_fork_args(args) {
Ok(parsed) => CommandResult::Action(Action::Fork(parsed)),
Err(msg) => CommandResult::Error(msg),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
// -- parse_fork_args ---------------------------------------------------
#[test]
fn parse_empty_returns_none_directive_and_no_override() {
let parsed = parse_fork_args("").expect("empty args parse");
assert_eq!(parsed.worktree_override, None);
assert_eq!(parsed.directive, None);
}
#[test]
fn parse_directive_only_returns_directive_with_no_override() {
let parsed =
parse_fork_args("explore the rate-limit hypothesis").expect("directive-only parse");
assert_eq!(parsed.worktree_override, None);
assert_eq!(
parsed.directive.as_deref(),
Some("explore the rate-limit hypothesis")
);
}
#[test]
fn parse_worktree_flag_alone_sets_override_true() {
let parsed = parse_fork_args("--worktree").expect("--worktree alone parse");
assert_eq!(parsed.worktree_override, Some(true));
assert_eq!(parsed.directive, None);
}
#[test]
fn parse_no_worktree_flag_alone_sets_override_false() {
let parsed = parse_fork_args("--no-worktree").expect("--no-worktree alone parse");
assert_eq!(parsed.worktree_override, Some(false));
assert_eq!(parsed.directive, None);
}
#[test]
fn parse_worktree_flag_with_directive_sets_both() {
let parsed = parse_fork_args("--worktree investigate the bug")
.expect("--worktree + directive parse");
assert_eq!(parsed.worktree_override, Some(true));
assert_eq!(parsed.directive.as_deref(), Some("investigate the bug"));
}
#[test]
fn parse_no_worktree_flag_with_directive_sets_both() {
let parsed =
parse_fork_args("--no-worktree quick fix").expect("--no-worktree + directive parse");
assert_eq!(parsed.worktree_override, Some(false));
assert_eq!(parsed.directive.as_deref(), Some("quick fix"));
}
#[test]
fn parse_worktree_then_no_worktree_is_mutual_exclusion_error() {
let err = parse_fork_args("--worktree --no-worktree x")
.expect_err("conflicting flags must error");
assert!(
err.contains("mutually exclusive"),
"error should explain mutual exclusion: {err}"
);
}
#[test]
fn parse_no_worktree_then_worktree_is_mutual_exclusion_error() {
let err = parse_fork_args("--no-worktree --worktree x")
.expect_err("conflicting flags must error");
assert!(
err.contains("mutually exclusive"),
"error should explain mutual exclusion: {err}"
);
}
#[test]
fn parse_worktree_repeated_returns_error() {
let err = parse_fork_args("--worktree --worktree foo")
.expect_err("duplicate --worktree must error");
assert!(
err.contains("twice"),
"error should mention duplicate: {err}"
);
}
#[test]
fn parse_at_flag_returns_friendly_v1_error() {
let err = parse_fork_args("--at 3 directive").expect_err("--at must error in v1");
assert!(
err.contains("--at is not supported"),
"error should mention --at deferral: {err}"
);
}
#[test]
fn parse_leading_whitespace_is_trimmed_before_flag_lookup() {
let parsed = parse_fork_args(" --worktree foo bar").expect("leading whitespace allowed");
assert_eq!(parsed.worktree_override, Some(true));
assert_eq!(parsed.directive.as_deref(), Some("foo bar"));
}
#[test]
fn parse_unknown_token_is_treated_as_directive_start() {
// Conservative behaviour: a bareword that isn't a recognised flag
// becomes the directive. This keeps `/fork --foo bar` from being
// rejected for typos -- the model just receives `--foo bar` as
// its first prompt.
let parsed = parse_fork_args("--foo bar").expect("unknown flag parse");
assert_eq!(parsed.worktree_override, None);
assert_eq!(parsed.directive.as_deref(), Some("--foo bar"));
}
#[test]
fn parse_extra_whitespace_between_flag_and_directive_is_trimmed() {
let parsed =
parse_fork_args("--worktree investigate").expect("extra whitespace allowed");
assert_eq!(parsed.worktree_override, Some(true));
assert_eq!(parsed.directive.as_deref(), Some("investigate"));
}
// -- ForkCommand SlashCommand impl ------------------------------------
fn make_ctx(models: &ModelState) -> CommandExecCtx<'_> {
let bundle = Box::leak(Box::new(crate::app::bundle::BundleState::default()));
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn run_no_args_returns_fork_action_with_default_args() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = ForkCommand;
match cmd.run(&mut ctx, "") {
CommandResult::Action(Action::Fork(args)) => {
assert_eq!(args.worktree_override, None);
assert_eq!(args.directive, None);
}
other => panic!("expected Action(Fork(..)), got {other:?}"),
}
}
#[test]
fn run_worktree_with_directive_returns_action_carrying_both() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = ForkCommand;
match cmd.run(&mut ctx, "--worktree fix the test") {
CommandResult::Action(Action::Fork(args)) => {
assert_eq!(args.worktree_override, Some(true));
assert_eq!(args.directive.as_deref(), Some("fix the test"));
}
other => panic!("expected Action(Fork(..)), got {other:?}"),
}
}
#[test]
fn run_conflicting_flags_returns_error_result() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = ForkCommand;
match cmd.run(&mut ctx, "--worktree --no-worktree") {
CommandResult::Error(msg) => {
assert!(msg.contains("mutually exclusive"), "got: {msg}");
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn run_at_flag_returns_error_result() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = ForkCommand;
match cmd.run(&mut ctx, "--at 5") {
CommandResult::Error(msg) => {
assert!(msg.contains("--at is not supported"), "got: {msg}");
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn metadata_matches_design() {
let cmd = ForkCommand;
assert_eq!(cmd.name(), "fork");
assert!(cmd.takes_args(), "/fork accepts args");
assert!(!cmd.args_required(), "/fork allows no args");
assert_eq!(cmd.arg_placeholder(), Some("[directive]"));
}
}

View file

@ -0,0 +1,46 @@
//! `/gboom` -- hidden easter egg. Launches a tiny raycaster shooter
//! rendered through the kitty graphics protocol.
//!
//! Never listed in the slash dropdown (`visible()` is false) but executes
//! when typed exactly as `/gboom`; with any argument it passes through to
//! the shell like an unknown command, so only the bare invocation triggers.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand};
/// Hidden GBOOM easter egg.
pub struct GboomCommand;
impl SlashCommand for GboomCommand {
fn name(&self) -> &str {
"gboom"
}
fn description(&self) -> &str {
// Never shown: the command is hidden from the dropdown.
"Hidden easter egg"
}
fn usage(&self) -> &str {
"/gboom"
}
/// Easter egg: typeable, never listed.
fn visible(&self, _ctx: &AppCtx) -> bool {
false
}
/// Needs an agent view to render in.
fn session_scoped(&self) -> bool {
true
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
if !args.trim().is_empty() {
// With arguments, behave as if the command didn't exist:
// forward the text to the shell/model with the args untouched.
return CommandResult::PassThrough(format!("/gboom {args}"));
}
CommandResult::Action(Action::OpenGboom)
}
}

View file

@ -0,0 +1,64 @@
//! `/help` -- open the command palette (the command + shortcut browser).
//!
//! In minimal mode there's no always-visible footer of hints, so `/help` is the
//! discoverable entry point (advertised in the status line). It opens the same
//! command palette as Ctrl+P, hosted inline by the overlay app-modal host.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open the command palette.
pub struct HelpCommand;
impl SlashCommand for HelpCommand {
fn name(&self) -> &str {
"help"
}
fn description(&self) -> &str {
"Browse commands and keyboard shortcuts"
}
fn usage(&self) -> &str {
"/help"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenCommandPalette)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
#[test]
fn dispatches_open_command_palette() {
let models = ModelState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
pager_state: PagerLocalSnapshot::default(),
};
assert!(matches!(
HelpCommand.run(&mut ctx, ""),
CommandResult::Action(Action::OpenCommandPalette)
));
}
}

View file

@ -0,0 +1,78 @@
//! `/history` -- open the prompt-history search overlay.
//!
//! Search mode over the panel Up-arrow browsing uses: fuzzy-search the
//! session's prior prompts, Enter/Tab drops the selection back into the
//! composer. The slash pipeline clears the composer before dispatch, so
//! the overlay opens with an empty query over the full history.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open the prompt-history search overlay via `/history`.
pub struct HistoryCommand;
impl SlashCommand for HistoryCommand {
fn name(&self) -> &str {
"history"
}
fn description(&self) -> &str {
"Search prompt history"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/history"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenHistorySearch)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
fn make_ctx<'a>(models: &'a ModelState, bundle: &'a BundleState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot::default(),
}
}
#[test]
fn run_dispatches_open_history_search() {
let cmd = HistoryCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle);
let result = cmd.run(&mut ctx, "");
assert!(matches!(
result,
CommandResult::Action(Action::OpenHistorySearch)
));
}
/// `/history` resolves via the real builtin registry (guards against a
/// name collision silently dropping it).
#[test]
fn resolves_via_builtin_registry() {
let reg = crate::slash::registry::CommandRegistry::new(
crate::slash::commands::builtin_commands(),
);
let resolved = reg
.get("history")
.expect("/history must resolve to a command");
assert_eq!(resolved.name(), "history");
}
}

View file

@ -0,0 +1,29 @@
//! `/home` -- exit the current session and return to the welcome screen.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Exit the current session and return to the welcome screen.
pub struct HomeCommand;
impl SlashCommand for HomeCommand {
fn name(&self) -> &str {
"home"
}
fn aliases(&self) -> &[&str] {
&["welcome"]
}
fn description(&self) -> &str {
"Return to the welcome screen"
}
fn usage(&self) -> &str {
"/home"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::ExitSession)
}
}

View file

@ -0,0 +1,108 @@
use agent_client_protocol as acp;
use xai_grok_tools::implementations::grok_build::{
IMAGE_GEN_TOOL_NAME, IMAGINE_COMMAND_NAME, imagine_instruction, imagine_usage_message,
};
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
const REQUIRED_TOOLS: &[&str] = &[IMAGE_GEN_TOOL_NAME];
pub struct ImagineCommand;
impl SlashCommand for ImagineCommand {
fn name(&self) -> &str {
IMAGINE_COMMAND_NAME
}
fn description(&self) -> &str {
"Generate an image from a text description"
}
fn usage(&self) -> &str {
"/imagine <description>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("description of the image to generate")
}
fn required_tools(&self) -> &[&str] {
REQUIRED_TOOLS
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let prompt = args.trim();
if prompt.is_empty() {
return CommandResult::Message(imagine_usage_message().to_string());
}
CommandResult::InjectSkill {
display_text: format!("/imagine {prompt}"),
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
imagine_instruction(prompt),
))],
display_as_skill: false,
scheduled_task_preview: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requires_image_gen_tool() {
assert_eq!(ImagineCommand.required_tools(), &["image_gen"]);
}
#[test]
fn empty_prompt_returns_usage() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ImagineCommand.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Message(_)));
}
#[test]
fn whitespace_prompt_returns_usage() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ImagineCommand.run(&mut ctx, " ");
assert!(matches!(result, CommandResult::Message(_)));
}
#[test]
fn valid_prompt_returns_inject_skill() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ImagineCommand.run(&mut ctx, "a golden sunset");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
display_as_skill,
..
} => {
assert_eq!(display_text, "/imagine a golden sunset");
assert!(!display_as_skill);
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
_ => panic!("expected Text block"),
};
assert!(text.contains("image_gen"));
assert!(text.contains("a golden sunset"));
}
other => panic!("expected InjectSkill, got {other:?}"),
}
}
}

View file

@ -0,0 +1,116 @@
use agent_client_protocol as acp;
use xai_grok_tools::implementations::grok_build::{
IMAGE_TO_VIDEO_TOOL_NAME, IMAGINE_VIDEO_COMMAND_NAME, imagine_video_instruction,
imagine_video_usage_message,
};
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
const REQUIRED_TOOLS: &[&str] = &[IMAGE_TO_VIDEO_TOOL_NAME];
pub struct ImagineVideoCommand;
impl SlashCommand for ImagineVideoCommand {
fn name(&self) -> &str {
IMAGINE_VIDEO_COMMAND_NAME
}
fn description(&self) -> &str {
"Generate a video from a text description"
}
fn usage(&self) -> &str {
"/imagine-video <description>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("description of the video to generate")
}
fn required_tools(&self) -> &[&str] {
REQUIRED_TOOLS
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let prompt = args.trim();
if prompt.is_empty() {
return CommandResult::Message(imagine_video_usage_message().to_string());
}
CommandResult::InjectSkill {
display_text: format!("/imagine-video {prompt}"),
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
imagine_video_instruction(prompt),
))],
display_as_skill: false,
scheduled_task_preview: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requires_image_to_video_tool() {
assert_eq!(ImagineVideoCommand.required_tools(), &["image_to_video"]);
}
#[test]
fn empty_prompt_returns_usage() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ImagineVideoCommand.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Message(_)));
}
#[test]
fn whitespace_prompt_returns_usage() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ImagineVideoCommand.run(&mut ctx, " ");
assert!(matches!(result, CommandResult::Message(_)));
}
#[test]
fn valid_prompt_returns_inject_skill() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ImagineVideoCommand.run(&mut ctx, "a cat playing piano");
match result {
CommandResult::InjectSkill {
display_text,
prompt_blocks,
display_as_skill,
..
} => {
assert_eq!(display_text, "/imagine-video a cat playing piano");
assert!(!display_as_skill);
assert_eq!(prompt_blocks.len(), 1);
let text = match &prompt_blocks[0] {
acp::ContentBlock::Text(t) => &t.text,
_ => panic!("expected Text block"),
};
assert!(
text.contains("image_to_video"),
"skill should reference image_to_video"
);
assert!(
text.contains("reference_to_video"),
"skill should reference reference_to_video"
);
assert!(text.contains("a cat playing piano"));
}
other => panic!("expected InjectSkill, got {other:?}"),
}
}
}

View file

@ -0,0 +1,36 @@
//! `/import-claude` -- open the interactive Claude settings import modal.
//!
//! This is the in-session entry point. The slash command dispatches the
//! shared `Action::ImportClaudeSettings` action; the dispatch handler
//! scans `.claude/settings*.json`, `~/.claude.json`, and `.mcp.json`,
//! populates the modal state, and the agent view overlays the modal on
//! top of the active session. The user gets the same selection UI as the
//! welcome screen's Ctrl-I shortcut.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open the interactive Claude settings import modal in the active session.
pub struct ImportClaudeCommand;
impl SlashCommand for ImportClaudeCommand {
fn name(&self) -> &str {
"import-claude"
}
fn description(&self) -> &str {
"Open the Claude settings import modal"
}
fn usage(&self) -> &str {
"/import-claude"
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if !trimmed.is_empty() {
tracing::warn!("/import-claude does not accept arguments; ignoring");
}
CommandResult::Action(Action::ImportClaudeSettings)
}
}

View file

@ -0,0 +1,74 @@
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct JumpCommand;
impl SlashCommand for JumpCommand {
fn name(&self) -> &str {
"jump"
}
fn description(&self) -> &str {
"Jump to a turn in the conversation"
}
fn session_scoped(&self) -> bool {
true
}
/// Minimal mode has no interactive scrollback pane to scroll — the
/// terminal's own scrollback covers it (same gate as `/find`).
fn available_in_minimal(&self) -> bool {
false
}
fn usage(&self) -> &str {
"/jump"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::JumpShowPicker)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
#[test]
fn jump_returns_show_picker_action() {
let models = ModelState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Fullscreen,
pager_state: PagerLocalSnapshot::default(),
};
let result = JumpCommand.run(&mut ctx, "");
assert!(matches!(
result,
CommandResult::Action(Action::JumpShowPicker)
));
}
#[test]
fn not_available_in_minimal() {
// Native terminal scrollback replaces in-app scrolling in minimal.
assert!(!JumpCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,24 @@
//! `/login` -- log in or re-authenticate with your account.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct LoginCommand;
impl SlashCommand for LoginCommand {
fn name(&self) -> &str {
"login"
}
fn description(&self) -> &str {
"Log in or re-authenticate with your account"
}
fn usage(&self) -> &str {
"/login"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::Login)
}
}

View file

@ -0,0 +1,24 @@
//! `/logout` -- remove auth credentials and return to the login screen.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct LogoutCommand;
impl SlashCommand for LogoutCommand {
fn name(&self) -> &str {
"logout"
}
fn description(&self) -> &str {
"Log out and return to the login screen"
}
fn usage(&self) -> &str {
"/logout"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::Logout)
}
}

View file

@ -0,0 +1,373 @@
use agent_client_protocol as acp;
use xai_grok_tools::implementations::grok_build::{
SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
};
use crate::slash::command::{CommandExecCtx, CommandResult, ScheduledTaskPreview, SlashCommand};
/// Pre-built slice for `LoopCommand::required_tools()`. Lifted to a
/// module-level constant so the trait method can return a `'static`
/// slice; the constant pulls the canonical name from `xai-grok-tools`
/// so a tool rename surfaces here at compile time.
const LOOP_REQUIRED_TOOLS: &[&str] = &[SCHEDULER_CREATE_TOOL_NAME];
pub struct LoopCommand;
/// Split `/loop` args into an optional leading compact interval token (only for
/// seeding the provisional preview) and the prompt. Returns `Some(token)` only
/// for a `^\d+[smhd]$` first token followed by prompt text; otherwise `None`,
/// leaving the model to derive the real interval. There is no host-side default.
fn parse_loop_args(args: &str) -> (Option<&str>, &str) {
let trimmed = args.trim();
if let Some(space) = trimmed.find(char::is_whitespace) {
let first = &trimmed[..space];
let rest = trimmed[space..].trim_start();
if is_interval_token(first) && !rest.is_empty() {
return (Some(first), rest);
}
}
(None, trimmed)
}
/// Whether a token is a schedulable interval: non-zero digits followed by one
/// of s/m/h/d. Zero is rejected so the preview never shows a cadence the tool
/// would reject (`parse_interval` errors on zero).
fn is_interval_token(s: &str) -> bool {
if s.len() < 2 {
return false;
}
let (digits, suffix) = s.split_at(s.len() - 1);
matches!(suffix, "s" | "m" | "h" | "d")
&& digits.chars().all(|c| c.is_ascii_digit())
&& digits.parse::<u64>().is_ok_and(|n| n > 0)
}
/// Convert an interval token like "5m" to a human string like "every 5 minutes".
fn interval_to_human(token: &str) -> String {
let (digits, suffix) = token.split_at(token.len() - 1);
let n: u64 = digits.parse().unwrap_or(0);
match suffix {
"s" => {
if n <= 1 {
"every 1 second".into()
} else {
format!("every {n} seconds")
}
}
"m" => {
if n == 1 {
"every 1 minute".into()
} else {
format!("every {n} minutes")
}
}
"h" => {
if n == 1 {
"every 1 hour".into()
} else {
format!("every {n} hours")
}
}
"d" => {
if n == 1 {
"every 1 day".into()
} else {
format!("every {n} days")
}
}
_ => format!("every {token}"),
}
}
impl SlashCommand for LoopCommand {
fn name(&self) -> &str {
"loop"
}
fn description(&self) -> &str {
"Run a prompt on a recurring interval"
}
fn usage(&self) -> &str {
"/loop [interval] <prompt>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[interval] <prompt>")
}
fn required_tools(&self) -> &[&str] {
LOOP_REQUIRED_TOOLS
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
if args.trim().is_empty() {
return CommandResult::Message(loop_usage_message().to_string());
}
let (interval_token, prompt) = parse_loop_args(args);
// Show a concrete cadence only for an unambiguous leading token;
// otherwise a neutral placeholder, since the authoritative schedule
// arrives via the model's scheduler_create -> ScheduledTaskCreated and
// replaces this provisional entry.
let human_schedule = match interval_token {
Some(token) => interval_to_human(token),
None => "scheduling…".to_string(),
};
CommandResult::InjectSkill {
display_text: format!("/loop {args}"),
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
loop_schedule_instruction(args),
))],
display_as_skill: false,
scheduled_task_preview: Some(ScheduledTaskPreview {
prompt: prompt.to_string(),
human_schedule,
next_fire_at: None,
tag: "loop".into(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::slash::command::CommandExecCtx;
#[test]
fn parse_with_explicit_interval() {
let (interval, prompt) = parse_loop_args("5m check deploy status");
assert_eq!(interval, Some("5m"));
assert_eq!(prompt, "check deploy status");
}
#[test]
fn parse_without_leading_interval_yields_none() {
let (interval, prompt) = parse_loop_args("check deploy status");
assert_eq!(interval, None);
assert_eq!(prompt, "check deploy status");
}
#[test]
fn parse_hours() {
let (interval, prompt) = parse_loop_args("2h run tests");
assert_eq!(interval, Some("2h"));
assert_eq!(prompt, "run tests");
}
#[test]
fn parse_days() {
let (interval, prompt) = parse_loop_args("1d daily report");
assert_eq!(interval, Some("1d"));
assert_eq!(prompt, "daily report");
}
#[test]
fn parse_seconds() {
let (interval, prompt) = parse_loop_args("60s ping health");
assert_eq!(interval, Some("60s"));
assert_eq!(prompt, "ping health");
}
#[test]
fn parse_interval_token_without_prompt_yields_none() {
// A bare interval token with no prompt text is treated as the prompt;
// there is no interval to extract for the preview.
let (interval, prompt) = parse_loop_args("5m");
assert_eq!(interval, None);
assert_eq!(prompt, "5m");
}
#[test]
fn parse_non_interval_first_token_yields_none() {
let (interval, prompt) = parse_loop_args("check 5m deploy");
assert_eq!(interval, None);
assert_eq!(prompt, "check 5m deploy");
}
#[test]
fn parse_empty_args_yields_none() {
assert_eq!(parse_loop_args(" "), (None, ""));
assert_eq!(parse_loop_args(""), (None, ""));
}
#[test]
fn malformed_leading_tokens_yield_none() {
// Exercises every rejecting branch of `is_interval_token` via
// `parse_loop_args`: bad suffix, missing suffix, too short, multi-char
// suffix, and zero-valued tokens. Each must fall through to the model
// with no host-side cadence.
for input in [
"5x do x", // bad suffix
"5 do x", // no suffix
"m do x", // too short / no digits
"55mm do x", // multi-char suffix
"0m do x", // zero value (tool would reject)
"0s do x", // zero value
"abc do x", // alphabetic
"99999999999999999999m do x", // overflows u64 -> parse Err branch
] {
let (interval, prompt) = parse_loop_args(input);
assert_eq!(interval, None, "input {input:?} must not yield a token");
assert_eq!(prompt, input);
}
}
#[test]
fn natural_language_intervals_are_not_defaulted_host_side() {
// The host no longer parses natural-language intervals or substitutes a
// default — these all fall through to the model with no interval token.
for input in [
"every 30 minutes do x",
"30 min check deploy",
"1 hour run report",
"run the report every 1h",
] {
let (interval, prompt) = parse_loop_args(input);
assert_eq!(interval, None, "input {input:?} must not yield a token");
assert_eq!(prompt, input.trim());
}
}
#[test]
fn interval_to_human_formats() {
assert_eq!(interval_to_human("5m"), "every 5 minutes");
assert_eq!(interval_to_human("1m"), "every 1 minute");
assert_eq!(interval_to_human("2h"), "every 2 hours");
assert_eq!(interval_to_human("1h"), "every 1 hour");
assert_eq!(interval_to_human("1d"), "every 1 day");
assert_eq!(interval_to_human("7d"), "every 7 days");
assert_eq!(interval_to_human("60s"), "every 60 seconds");
}
fn run_loop(args: &str) -> CommandResult {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
LoopCommand.run(&mut ctx, args)
}
#[test]
fn run_with_leading_token_shows_concrete_schedule() {
match run_loop("30m check deploy status") {
CommandResult::InjectSkill {
scheduled_task_preview: Some(preview),
..
} => {
assert_eq!(preview.human_schedule, "every 30 minutes");
assert_eq!(preview.prompt, "check deploy status");
}
other => panic!("expected InjectSkill with preview, got {other:?}"),
}
}
#[test]
fn run_without_leading_token_shows_placeholder_not_default() {
match run_loop("check deploy status every 30 minutes") {
CommandResult::InjectSkill {
scheduled_task_preview: Some(preview),
..
} => {
// No fabricated cadence — the model fills in the real schedule.
assert_eq!(preview.human_schedule, "scheduling…");
assert_ne!(preview.human_schedule, "every 10 minutes");
assert_eq!(preview.prompt, "check deploy status every 30 minutes");
}
other => panic!("expected InjectSkill with preview, got {other:?}"),
}
}
#[test]
fn run_bare_leading_token_shows_placeholder() {
// "/loop 5m" with no prompt text: nothing to extract, so the preview
// shows the placeholder and the whole input becomes the prompt.
match run_loop("5m") {
CommandResult::InjectSkill {
scheduled_task_preview: Some(preview),
..
} => {
assert_eq!(preview.human_schedule, "scheduling…");
assert_eq!(preview.prompt, "5m");
}
other => panic!("expected InjectSkill with preview, got {other:?}"),
}
}
#[test]
fn run_instruction_drops_host_default_and_explains_parsing() {
match run_loop("every 30 minutes do x") {
CommandResult::InjectSkill { prompt_blocks, .. } => {
let acp::ContentBlock::Text(text) = &prompt_blocks[0] else {
panic!("expected a text prompt block");
};
let instruction = &text.text;
assert!(
!instruction.contains("10m"),
"instruction must not advertise a 10m default: {instruction}"
);
// Stable, behaviour-bearing tokens, not incidental example text.
assert!(instruction.contains("30 minutes"));
assert!(instruction.contains("<number><unit>"));
assert!(instruction.contains("ask the user how often"));
}
other => panic!("expected InjectSkill, got {other:?}"),
}
}
#[test]
fn run_empty_args_returns_usage_without_default_claim() {
match run_loop(" ") {
CommandResult::Message(msg) => {
assert!(msg.contains("Usage: /loop"));
assert!(
!msg.contains("10m"),
"usage must not claim a 10m default: {msg}"
);
}
other => panic!("expected usage Message, got {other:?}"),
}
}
// Drift guard (pager end): pager text == shared helper. With the shell's
// `loop_prompt_matches_pager_wording`, this pins full shell↔pager parity.
#[test]
fn run_instruction_matches_shared_helper() {
let args = "2h run tests";
match run_loop(args) {
CommandResult::InjectSkill { prompt_blocks, .. } => {
let acp::ContentBlock::Text(text) = &prompt_blocks[0] else {
panic!("expected a text prompt block");
};
assert_eq!(text.text, loop_schedule_instruction(args));
}
other => panic!("expected InjectSkill, got {other:?}"),
}
}
#[test]
fn run_usage_matches_shared_helper() {
match run_loop(" ") {
CommandResult::Message(msg) => assert_eq!(msg, loop_usage_message()),
other => panic!("expected usage Message, got {other:?}"),
}
}
}

View file

@ -0,0 +1,25 @@
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct McpsCommand;
impl SlashCommand for McpsCommand {
fn name(&self) -> &str {
"mcps"
}
fn description(&self) -> &str {
"Show MCP server status"
}
fn usage(&self) -> &str {
"/mcps"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenExtensionsModal {
tab: crate::views::extensions_modal::ExtensionsTab::McpServers,
trigger: xai_grok_telemetry::events::ExtensionsModalTrigger::SlashCommand,
})
}
}

View file

@ -0,0 +1,683 @@
//! Concrete slash command implementations.
//!
//! Each command lives in its own submodule. This module re-exports
//! command structs and provides `builtin_commands()` for registry
//! construction.
pub mod always_approve;
pub mod announcements;
pub mod auto;
pub mod btw;
pub mod cd;
pub mod compact;
pub mod compact_mode;
pub mod config_agents;
pub mod context;
pub mod copy;
pub mod dashboard;
pub mod debug;
pub mod docs;
pub mod effort;
pub mod effort_levels;
pub mod exit;
pub mod expand;
pub mod export;
pub mod feedback;
pub mod find;
pub mod fork;
pub mod gboom;
pub mod help;
pub mod history;
pub mod home;
pub mod imagine;
pub mod imagine_video;
pub mod import_claude;
pub mod jump;
pub mod login;
pub mod logout;
pub mod loop_cmd;
pub mod mcps;
pub mod model;
pub mod multiline;
pub mod new;
pub mod personas;
pub mod plan;
pub mod plugin;
pub mod privacy;
pub mod queue;
pub mod recap;
pub mod release_notes;
pub mod remember;
pub mod rename;
pub mod resume;
pub mod rewind;
pub mod screen_mode_switch;
pub mod scroll_debug;
pub mod session_info;
pub mod settings_cmd;
pub mod share;
pub mod tasks;
pub mod terminal_setup;
pub mod theme;
pub mod timeline;
pub mod timestamps;
pub mod toggle_mouse_reporting;
pub mod transcript;
pub mod usage;
pub mod view_plan;
pub mod vim_mode;
pub mod voice;
use super::command::SlashCommand;
use std::sync::Arc;
/// All pager-local builtin commands, in display order.
///
/// This is the single source of truth for the builtin command set.
/// The registry is constructed from this list.
pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
vec![
Arc::new(exit::ExitCommand),
Arc::new(help::HelpCommand),
Arc::new(docs::DocsCommand),
Arc::new(home::HomeCommand),
Arc::new(new::NewCommand),
Arc::new(fork::ForkCommand),
Arc::new(compact::CompactCommand),
Arc::new(copy::CopyCommand),
Arc::new(find::FindCommand),
Arc::new(history::HistoryCommand),
Arc::new(export::ExportCommand),
Arc::new(transcript::TranscriptCommand),
Arc::new(expand::ExpandCommand),
Arc::new(context::ContextCommand),
Arc::new(screen_mode_switch::ScreenModeSwitchCommand::minimal()),
Arc::new(screen_mode_switch::ScreenModeSwitchCommand::fullscreen()),
Arc::new(model::ModelCommand),
Arc::new(effort::EffortCommand),
Arc::new(always_approve::AlwaysApproveCommand),
Arc::new(auto::AutoCommand),
Arc::new(multiline::MultilineCommand),
Arc::new(compact_mode::CompactModeCommand),
Arc::new(vim_mode::VimModeCommand),
Arc::new(plugin::HooksCommand),
Arc::new(plugin::PluginsCommand),
Arc::new(plugin::MarketplaceCommand),
Arc::new(plugin::SkillsCommand),
Arc::new(share::ShareCommand),
Arc::new(session_info::SessionInfoCommand),
Arc::new(rename::RenameCommand),
Arc::new(dashboard::DashboardCommand),
Arc::new(cd::CdCommand),
Arc::new(theme::ThemeCommand),
Arc::new(feedback::FeedbackCommand),
Arc::new(announcements::AnnouncementsCommand),
Arc::new(remember::RememberCommand),
Arc::new(plan::PlanCommand),
Arc::new(view_plan::ViewPlanCommand),
Arc::new(resume::ResumeCommand),
Arc::new(mcps::McpsCommand),
Arc::new(btw::BtwCommand),
Arc::new(recap::RecapCommand),
Arc::new(terminal_setup::TerminalSetupCommand),
Arc::new(voice::VoiceCommand),
Arc::new(loop_cmd::LoopCommand),
Arc::new(imagine::ImagineCommand),
Arc::new(imagine_video::ImagineVideoCommand),
Arc::new(timestamps::TimestampsCommand),
Arc::new(timeline::TimelineCommand),
Arc::new(toggle_mouse_reporting::ToggleMouseReportingCommand),
Arc::new(settings_cmd::SettingsCommand),
Arc::new(privacy::PrivacyCommand),
Arc::new(rewind::RewindCommand),
Arc::new(jump::JumpCommand),
Arc::new(login::LoginCommand),
Arc::new(logout::LogoutCommand),
Arc::new(import_claude::ImportClaudeCommand),
Arc::new(usage::UsageCommand),
Arc::new(queue::QueueCommand),
Arc::new(tasks::TasksCommand),
Arc::new(release_notes::ReleaseNotesCommand),
Arc::new(config_agents::ConfigAgentsCommand),
Arc::new(personas::PersonasCommand),
Arc::new(gboom::GboomCommand),
Arc::new(scroll_debug::ScrollDebugCommand),
Arc::new(debug::DebugCommand),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult};
use crate::slash::registry::CommandRegistry;
use agent_client_protocol as acp;
/// Build a ModelState with two models for testing.
fn sample_models() -> ModelState {
let mut models = ModelState::default();
let id_fast = acp::ModelId::new(Arc::from("grok-4.5"));
models.available.insert(
id_fast.clone(),
acp::ModelInfo::new(id_fast.clone(), "Grok 4.5".to_string()),
);
let id_pro = acp::ModelId::new(Arc::from("grok-4.3"));
models.available.insert(
id_pro.clone(),
acp::ModelInfo::new(id_pro.clone(), "Grok 4.3".to_string()),
);
models.current = Some(id_fast);
models
}
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
pub(crate) fn make_ctx(models: &ModelState) -> CommandExecCtx<'_> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn builtin_registry_lookup_by_canonical() {
let mut reg = CommandRegistry::new(builtin_commands());
assert!(reg.get("quit").is_some());
assert!(reg.get("new").is_some());
assert!(reg.get("compact").is_some());
assert!(reg.get("model").is_some());
assert!(reg.get("home").is_some());
assert!(reg.get("view-plan").is_some());
reg.set_available_tools(std::collections::HashSet::from([
"scheduler_create".to_string()
]));
assert!(reg.get("loop").is_some(), "/loop should be registered");
assert!(
reg.get("vim-mode").is_some(),
"/vim-mode should be registered"
);
assert!(reg.get("find").is_some(), "/find should be registered");
}
#[test]
fn loop_command_declares_scheduler_tool_requirement() {
let loop_cmd = loop_cmd::LoopCommand;
assert_eq!(loop_cmd.required_tools(), &["scheduler_create"]);
}
#[test]
fn loop_command_hidden_when_scheduler_tools_absent() {
let mut reg = CommandRegistry::new(builtin_commands());
reg.set_available_tools(std::collections::HashSet::from([
"read_file".to_string(),
"grep".to_string(),
]));
assert!(reg.get("loop").is_none(), "/loop should be hidden");
assert!(reg.get("quit").is_some());
assert!(reg.get("compact").is_some());
reg.set_available_tools(std::collections::HashSet::from([
"scheduler_create".to_string()
]));
assert!(reg.get("loop").is_some());
}
#[test]
fn builtin_registry_lookup_by_alias() {
let reg = CommandRegistry::new(builtin_commands());
assert!(reg.get("exit").is_some());
assert!(reg.get("clear").is_some());
assert!(reg.get("m").is_some());
assert!(reg.get("welcome").is_some());
assert!(reg.get("show-plan").is_some());
assert!(reg.get("plan-view").is_some());
}
#[test]
fn alias_resolves_to_same_command() {
let reg = CommandRegistry::new(builtin_commands());
let exit_cmd = reg.get("exit").unwrap();
let quit_cmd = reg.get("quit").unwrap();
assert_eq!(exit_cmd.name(), quit_cmd.name());
}
#[test]
fn exit_returns_quit_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = exit::ExitCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Action(Action::Quit)));
}
#[test]
fn new_returns_new_session_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = new::NewCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Action(Action::NewSession)));
}
#[test]
fn home_returns_exit_session_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = home::HomeCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Action(Action::ExitSession)));
}
#[test]
fn view_plan_returns_show_plan_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = view_plan::ViewPlanCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Action(Action::ShowPlan)));
}
#[test]
fn compact_no_args_returns_queue_command() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = compact::CompactCommand;
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::QueueCommand(text) => assert_eq!(text, "/compact"),
other => panic!("expected QueueCommand, got {other:?}"),
}
}
#[test]
fn compact_with_context_returns_queue_command_with_args() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = compact::CompactCommand;
let result = cmd.run(&mut ctx, "focus on auth");
match result {
CommandResult::QueueCommand(text) => {
assert_eq!(text, "/compact focus on auth")
}
other => panic!("expected QueueCommand, got {other:?}"),
}
}
#[test]
fn compact_whitespace_only_args_treated_as_no_args() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = compact::CompactCommand;
let result = cmd.run(&mut ctx, " ");
match result {
CommandResult::QueueCommand(text) => assert_eq!(text, "/compact"),
other => panic!("expected QueueCommand, got {other:?}"),
}
}
/// Bare `/model <name>` → `SetDefaultModel` (switch + persist).
/// `/model <name> <effort>` → `SwitchModel` (session-scoped).
#[test]
fn model_resolves_by_display_name() {
let models = sample_models();
let mut ctx = make_ctx(&models);
let cmd = model::ModelCommand;
let result = cmd.run(&mut ctx, "Grok 4.5");
match result {
CommandResult::Action(Action::SetDefaultModel(id)) => {
assert_eq!(id.0.as_ref(), "grok-4.5");
}
other => panic!("expected Action(SetDefaultModel), got {other:?}"),
}
}
#[test]
fn model_resolves_by_model_id() {
let models = sample_models();
let mut ctx = make_ctx(&models);
let cmd = model::ModelCommand;
let result = cmd.run(&mut ctx, "grok-4.3");
match result {
CommandResult::Action(Action::SetDefaultModel(id)) => {
assert_eq!(id.0.as_ref(), "grok-4.3");
}
other => panic!("expected Action(SetDefaultModel), got {other:?}"),
}
}
#[test]
fn model_resolves_case_insensitively() {
let models = sample_models();
let mut ctx = make_ctx(&models);
let cmd = model::ModelCommand;
let result = cmd.run(&mut ctx, "grok 4.5");
match result {
CommandResult::Action(Action::SetDefaultModel(id)) => {
assert_eq!(id.0.as_ref(), "grok-4.5");
}
other => panic!("expected Action(SetDefaultModel), got {other:?}"),
}
}
#[test]
fn model_invalid_arg_returns_error() {
let models = sample_models();
let mut ctx = make_ctx(&models);
let cmd = model::ModelCommand;
let result = cmd.run(&mut ctx, "nonexistent-model");
match result {
CommandResult::Error(msg) => {
assert!(
msg.contains("nonexistent-model"),
"error should contain the arg"
);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn model_empty_arg_returns_error() {
let models = sample_models();
let mut ctx = make_ctx(&models);
let cmd = model::ModelCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Error(_)));
}
#[test]
fn model_whitespace_only_arg_returns_error() {
let models = sample_models();
let mut ctx = make_ctx(&models);
let cmd = model::ModelCommand;
let result = cmd.run(&mut ctx, " ");
assert!(matches!(result, CommandResult::Error(_)));
}
#[test]
fn model_suggest_args_returns_available_models() {
let models = sample_models();
let ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let cmd = model::ModelCommand;
let items = cmd.suggest_args(&ctx, "").expect("should have suggestions");
assert_eq!(items.len(), 2);
assert!(
items
.iter()
.any(|i| i.display.starts_with("Grok 4.5") && i.insert_text == "Grok 4.5")
);
assert!(
items
.iter()
.any(|i| i.display == "Grok 4.3" && i.insert_text == "Grok 4.3")
);
}
#[test]
fn model_suggest_args_empty_models_returns_none() {
let models = ModelState::default();
let ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let cmd = model::ModelCommand;
assert!(cmd.suggest_args(&ctx, "").is_none());
}
#[test]
fn remember_no_args_enters_remember_mode() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = remember::RememberCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(
result,
CommandResult::Action(Action::EnterRememberMode)
));
}
#[test]
fn remember_with_args_sends_note() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = remember::RememberCommand;
let result = cmd.run(&mut ctx, "important detail");
match result {
CommandResult::Action(Action::SendRememberNote(text)) => {
assert_eq!(text, "important detail");
}
other => panic!("expected SendRememberNote, got {other:?}"),
}
}
#[test]
fn remember_whitespace_only_enters_remember_mode() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = remember::RememberCommand;
let result = cmd.run(&mut ctx, " ");
assert!(matches!(
result,
CommandResult::Action(Action::EnterRememberMode)
));
}
fn run_usage(args: &str) -> CommandResult {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
usage::UsageCommand.run(&mut ctx, args)
}
#[test]
fn usage_no_args_returns_show_usage() {
assert!(matches!(
run_usage(""),
CommandResult::Action(Action::ShowUsage)
));
}
#[test]
fn usage_show_returns_show_usage() {
assert!(matches!(
run_usage("show"),
CommandResult::Action(Action::ShowUsage)
));
}
#[test]
fn usage_manage_returns_open_url() {
match run_usage("manage") {
CommandResult::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "https://grok.com/?_s=usage");
}
other => panic!("expected Action(OpenUrl), got {other:?}"),
}
}
#[test]
fn usage_invalid_arg_returns_error() {
match run_usage("delete") {
CommandResult::Error(msg) => {
assert!(msg.contains("delete"), "got: {msg}");
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn usage_whitespace_only_treated_as_no_args() {
assert!(matches!(
run_usage(" "),
CommandResult::Action(Action::ShowUsage)
));
}
#[test]
fn usage_show_with_leading_whitespace() {
assert!(matches!(
run_usage(" show "),
CommandResult::Action(Action::ShowUsage)
));
}
#[test]
fn usage_manage_with_leading_whitespace() {
match run_usage(" manage ") {
CommandResult::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "https://grok.com/?_s=usage");
}
other => panic!("expected Action(OpenUrl), got {other:?}"),
}
}
#[test]
fn usage_suggest_args_returns_show_and_manage() {
let models = ModelState::default();
let ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = usage::UsageCommand
.suggest_args(&ctx, "")
.expect("should have suggestions");
assert_eq!(items.len(), 2);
assert_eq!(items[0].display, "show");
assert_eq!(items[0].insert_text, "show");
assert_eq!(items[1].display, "manage");
assert_eq!(items[1].insert_text, "manage");
}
#[test]
fn usage_metadata() {
let cmd = usage::UsageCommand;
assert_eq!(cmd.name(), "usage");
assert!(cmd.takes_args());
assert_eq!(cmd.arg_placeholder(), Some("show | manage"));
assert!(!cmd.description().is_empty());
assert!(!cmd.usage().is_empty());
}
#[test]
fn usage_registered_in_builtin_commands() {
let reg = CommandRegistry::new(builtin_commands());
assert!(
reg.get("usage").is_some(),
"/usage should be registered in builtins"
);
}
#[test]
fn cd_registered_in_builtin_commands() {
let reg = CommandRegistry::new(builtin_commands());
assert!(
reg.get("cd").is_some(),
"/cd should be registered in builtins"
);
}
#[test]
fn queue_registered_in_builtin_commands() {
let reg = CommandRegistry::new(builtin_commands());
assert!(
reg.get("queue").is_some(),
"/queue should be registered in builtins"
);
}
#[test]
fn tasks_registered_in_builtin_commands() {
let reg = CommandRegistry::new(builtin_commands());
assert!(
reg.get("tasks").is_some(),
"/tasks should be registered in builtins"
);
}
#[test]
fn cost_aliases_usage() {
let reg = CommandRegistry::new(builtin_commands());
let cost = reg.get("cost").expect("/cost should resolve");
assert_eq!(cost.name(), "usage", "/cost must alias /usage");
}
#[test]
fn debug_is_registered_and_executable() {
let reg = CommandRegistry::new(builtin_commands());
assert!(reg.get("debug").is_some(), "/debug must be executable");
}
#[test]
fn gboom_is_registered_and_executable() {
let reg = CommandRegistry::new(builtin_commands());
assert!(reg.get("gboom").is_some(), "/gboom must be executable");
}
#[test]
fn gboom_is_invisible() {
let models = ModelState::default();
let ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(
!gboom::GboomCommand.visible(&ctx),
"/gboom must never appear in the dropdown"
);
}
#[test]
fn minimal_and_fullscreen_registered_in_builtin_commands() {
let reg = CommandRegistry::new(builtin_commands());
assert!(reg.get("minimal").is_some());
assert!(reg.get("fullscreen").is_some());
assert!(reg.get("full").is_some());
assert_eq!(
reg.get("full").unwrap().name(),
reg.get("fullscreen").unwrap().name()
);
}
#[test]
fn recap_registered_in_builtin_commands() {
let mut reg = CommandRegistry::new(builtin_commands());
reg.set_recap_visible(true);
assert!(
reg.get("recap").is_some(),
"/recap should be registered in builtins"
);
}
#[test]
fn gboom_bare_invocation_opens_game() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let result = gboom::GboomCommand.run(&mut ctx, "");
assert!(matches!(result, CommandResult::Action(Action::OpenGboom)));
let result = gboom::GboomCommand.run(&mut ctx, " ");
assert!(matches!(result, CommandResult::Action(Action::OpenGboom)));
}
#[test]
fn gboom_with_args_passes_through_to_shell() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
match gboom::GboomCommand.run(&mut ctx, "guide me") {
CommandResult::PassThrough(text) => assert_eq!(text, "/gboom guide me"),
other => panic!("expected PassThrough, got {other:?}"),
}
}
#[test]
fn recap_returns_manual_send_recap_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = recap::RecapCommand;
let result = cmd.run(&mut ctx, "");
assert!(matches!(
result,
CommandResult::Action(Action::SendRecap { auto: false })
));
}
#[test]
fn recap_hidden_by_default_in_registry_until_revealed() {
let mut reg = CommandRegistry::new(builtin_commands());
assert!(
reg.get("recap").is_none(),
"/recap must be fail-closed until shell advertises sessionRecap"
);
reg.set_recap_visible(true);
assert!(reg.get("recap").is_some());
reg.set_recap_visible(false);
assert!(reg.get("recap").is_none());
}
#[test]
fn voice_hidden_by_default_in_registry_until_revealed() {
let mut reg = CommandRegistry::new(builtin_commands());
assert!(
reg.get("voice").is_none(),
"/voice must be fail-closed until set_voice_visible(true)"
);
reg.set_voice_visible(true);
assert!(reg.get("voice").is_some());
reg.set_voice_visible(false);
assert!(reg.get("voice").is_none());
}
}

View file

@ -0,0 +1,484 @@
//! `/model` (alias `/m`) — switch model + (optionally) reasoning effort.
//! Chained autocomplete: pick a reasoning-supported model → trailing space
//! re-opens the dropdown into a `low|medium|high|xhigh` sub-menu.
use agent_client_protocol as acp;
use xai_grok_shell::sampling::types::supports_reasoning_effort_meta;
use crate::acp::model_state::ModelState;
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
use crate::slash::commands::effort_levels::build_effort_arg_items;
/// Switch the active model (and optionally its reasoning effort).
pub struct ModelCommand;
impl SlashCommand for ModelCommand {
fn name(&self) -> &str {
"model"
}
fn aliases(&self) -> &[&str] {
&["m"]
}
fn description(&self) -> &str {
"Switch the active model"
}
fn session_scoped(&self) -> bool {
true
}
fn offered_when_session_less(&self) -> bool {
// The dashboard offers `/model` to pick the model for the next
// spawned agent (intercepted in `dispatch_dashboard_dispatch_slash`).
true
}
fn usage(&self) -> &str {
"/model <name> [effort]"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("<model> [effort]")
}
fn suggest_args(&self, ctx: &AppCtx, args_query: &str) -> Option<Vec<ArgItem>> {
if ctx.models.is_empty() {
return None;
}
// Effort phase if input is "<reasoning-model> ", else model phase.
if let Some(model_id) = detect_effort_phase(ctx.models, args_query) {
return Some(build_effort_items(ctx.models, &model_id));
}
Some(build_model_items(ctx.models))
}
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if trimmed.is_empty() {
return CommandResult::Error("Usage: /model <name> [effort]".into());
}
// Prefer an exact full-string catalog match first. Model display names
// often contain spaces ("Grok 4.5"); if we split on the last token
// first, a shorter catalog entry ("Grok") would steal the prefix and
// treat "4.5" as an effort level.
if let Some(id) = ctx.models.resolve_by_name_or_id(trimmed) {
return CommandResult::Action(Action::SetDefaultModel(id));
}
// Trailing effort token + reasoning model → session-scoped switch
// (not persisted as default). Resolve via the shared gate so a rejected
// level (e.g. `none` on grok-4.5) surfaces the effort error with the
// model's offered ids — not "Unknown model: … none".
if let Some((prefix, token)) = split_trailing_token(trimmed)
&& let Some(id) = resolve_model(ctx.models, prefix)
&& ctx
.models
.available
.get(&id)
.map(supports_reasoning_effort)
.unwrap_or(false)
{
return match ctx.models.resolve_effort_for_model(&id, token) {
Ok(effort) => CommandResult::Action(Action::SwitchModel {
model_id: id,
effort: Some(effort),
}),
Err(err) => CommandResult::Error(err.message()),
};
}
CommandResult::Error(format!("Unknown model: {trimmed}"))
}
}
/// Look up a model by case-insensitive display name OR model id match.
fn resolve_model(models: &ModelState, name: &str) -> Option<acp::ModelId> {
models.resolve_by_name_or_id(name)
}
fn supports_reasoning_effort(info: &acp::ModelInfo) -> bool {
supports_reasoning_effort_meta(info.meta.as_ref())
}
/// Split `args` into `(prefix, last_token)` on the final whitespace run.
/// Returns `None` when there is no interior whitespace to split on. The token is
/// resolved to an effort against the picked model's options by the caller.
fn split_trailing_token(args: &str) -> Option<(&str, &str)> {
let (prefix, last) = args.rsplit_once(char::is_whitespace)?;
let prefix = prefix.trim_end();
if prefix.is_empty() || last.is_empty() {
return None;
}
Some((prefix, last))
}
/// Returns the matched model id when `args_query` is `"<reasoning-model> ..."`.
/// Longest-name-first to disambiguate names that share a prefix.
fn detect_effort_phase(models: &ModelState, args_query: &str) -> Option<acp::ModelId> {
let mut candidates: Vec<(&acp::ModelId, &str)> = models
.available
.iter()
.filter(|(_, info)| supports_reasoning_effort(info))
.map(|(id, info)| (id, info.name.as_str()))
.collect();
candidates.sort_by_key(|(_, name)| std::cmp::Reverse(name.len()));
for (id, name) in candidates {
if args_query.len() > name.len()
&& args_query.is_char_boundary(name.len())
&& args_query[..name.len()].eq_ignore_ascii_case(name)
&& args_query[name.len()..].starts_with(char::is_whitespace)
{
return Some(id.clone());
}
}
None
}
/// One row per logical model. Reasoning models get a trailing space in
/// `insert_text` so the prompt widget chains into the effort sub-menu.
fn build_model_items(models: &ModelState) -> Vec<ArgItem> {
let current_id = models.current.as_ref();
let mut items: Vec<ArgItem> = Vec::with_capacity(models.available.len());
for (id, info) in &models.available {
let is_current = current_id == Some(id);
let supports = supports_reasoning_effort(info);
let display = if is_current {
format!("{} (current)", info.name)
} else {
info.name.clone()
};
// Trailing space on reasoning models: signals "more input
// expected" to the prompt widget so Enter advances to effort
// phase instead of submitting.
let insert_text = if supports {
format!("{} ", info.name)
} else {
info.name.clone()
};
items.push(ArgItem {
display,
match_text: info.name.clone(),
insert_text,
description: info.description.clone().unwrap_or_default(),
});
}
items
}
/// One row per effort level for the `/model` chained effort phase.
/// `insert_text` is `"ModelName high"` so selecting a row completes both tokens.
fn build_effort_items(models: &ModelState, model_id: &acp::ModelId) -> Vec<ArgItem> {
let info = match models.available.get(model_id) {
Some(info) => info,
None => return Vec::new(),
};
let model_name = info.name.clone();
let is_current_model = models.current.as_ref() == Some(model_id);
let options = models.reasoning_effort_options_for(model_id);
build_effort_arg_items(
&options,
models.reasoning_effort,
is_current_model,
|option| format!("{model_name} {}", option.id),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use xai_grok_shell::sampling::types::ReasoningEffort;
fn model_with_reasoning(id: &str, name: &str) -> (acp::ModelId, acp::ModelInfo) {
let id = acp::ModelId::new(Arc::from(id));
let mut meta = serde_json::Map::new();
meta.insert(
"supportsReasoningEffort".into(),
serde_json::Value::Bool(true),
);
let info = acp::ModelInfo::new(id.clone(), name.to_string())
.meta(serde_json::Value::Object(meta).as_object().cloned());
(id, info)
}
fn plain_model(id: &str, name: &str) -> (acp::ModelId, acp::ModelInfo) {
let id = acp::ModelId::new(Arc::from(id));
let info = acp::ModelInfo::new(id.clone(), name.to_string());
(id, info)
}
static EMPTY_BUNDLE: crate::app::bundle::BundleState = crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn dummy_exec_ctx(models: &ModelState) -> CommandExecCtx<'_> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &EMPTY_BUNDLE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn split_trailing_token_splits_on_final_whitespace() {
assert_eq!(
split_trailing_token("Reasoning X high"),
Some(("Reasoning X", "high"))
);
assert_eq!(
split_trailing_token("reasoning-x xhigh"),
Some(("reasoning-x", "xhigh"))
);
// No interior whitespace → nothing to split off.
assert!(split_trailing_token("reasoning-x-pro").is_none());
}
#[test]
fn empty_query_returns_one_row_per_logical_model() {
let mut state = ModelState::default();
let (rid, rinfo) = model_with_reasoning("reasoning-x", "Reasoning X");
let (pid, pinfo) = plain_model("grok-4.5", "Grok 4.5");
state.available.insert(rid, rinfo);
state.available.insert(pid, pinfo);
let cmd = ModelCommand;
let ctx = AppCtx {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").unwrap();
assert_eq!(items.len(), 2, "model phase: one row per logical model");
// Reasoning model has trailing space in insert_text -- this is the
// signal the prompt widget reads to keep the dropdown open after
// Enter so the effort sub-menu can render.
let reasoning = items
.iter()
.find(|i| i.match_text == "Reasoning X")
.unwrap();
assert_eq!(reasoning.insert_text, "Reasoning X ");
// Plain model has no trailing space -- Enter commits immediately.
let plain = items.iter().find(|i| i.match_text == "Grok 4.5").unwrap();
assert_eq!(plain.insert_text, "Grok 4.5");
}
#[test]
fn trailing_space_after_reasoning_model_enters_effort_phase() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id, info);
let cmd = ModelCommand;
let ctx = AppCtx {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
// Args query has a trailing space -> effort phase. Items come out
// ordered xhigh -> low (strongest first) per EFFORT_LEVELS.
let items = cmd.suggest_args(&ctx, "Reasoning X ").unwrap();
assert_eq!(items.len(), 4);
assert_eq!(items[0].insert_text, "Reasoning X xhigh");
assert_eq!(items[1].insert_text, "Reasoning X high");
assert_eq!(items[2].insert_text, "Reasoning X medium");
assert_eq!(items[3].insert_text, "Reasoning X low");
// Display is just the level so the user sees a clean column.
assert_eq!(items[0].display, "xhigh");
// match_text carries the sort-key prefix that forces the matcher's
// alphabetical tiebreak to render rows in EFFORT_LEVELS order.
assert!(items[0].match_text.starts_with("a "));
assert!(items[3].match_text.starts_with("d "));
}
#[test]
fn partial_effort_query_still_in_effort_phase() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id, info);
let cmd = ModelCommand;
let ctx = AppCtx {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
// Still in effort phase; matcher upstream narrows to high / xhigh.
let items = cmd.suggest_args(&ctx, "Reasoning X h").unwrap();
assert_eq!(items.len(), 4);
}
#[test]
fn partial_model_query_stays_in_model_phase() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id, info);
let cmd = ModelCommand;
let ctx = AppCtx {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
// No trailing space, user is still typing the model name.
let items = cmd.suggest_args(&ctx, "Reason").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].insert_text, "Reasoning X ");
}
#[test]
fn run_parses_model_plus_effort_when_supported() {
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id, info);
let mut ctx = dummy_exec_ctx(&state);
let result = ModelCommand.run(&mut ctx, "Reasoning X xhigh");
match result {
CommandResult::Action(Action::SwitchModel { model_id, effort }) => {
assert_eq!(model_id.0.as_ref(), "reasoning-x");
assert_eq!(effort, Some(ReasoningEffort::Xhigh));
}
other => panic!("expected SwitchModel with effort, got {other:?}"),
}
}
#[test]
fn run_rejects_unoffered_effort_with_effort_error_not_unknown_model() {
// Regression: previously `resolve_effort_token_for` returned None and
// the handler fell through to `Unknown model: Reasoning X none`.
let mut state = ModelState::default();
let (id, info) = model_with_reasoning("reasoning-x", "Reasoning X");
state.available.insert(id, info);
let mut ctx = dummy_exec_ctx(&state);
let result = ModelCommand.run(&mut ctx, "Reasoning X none");
match result {
CommandResult::Error(msg) => {
assert!(
msg.contains("unknown effort level 'none'"),
"expected effort error, got {msg}"
);
assert!(
msg.contains("use one of:"),
"expected offered levels in message, got {msg}"
);
assert!(
!msg.to_lowercase().contains("unknown model"),
"must not misreport as unknown model: {msg}"
);
let offered = msg.split_once("; ").map(|(_, r)| r).unwrap_or("");
assert!(
!offered.contains("none"),
"must not list none as offered: {msg}"
);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn run_prefers_full_multi_word_model_name_over_prefix_plus_effort() {
// Catalog has both "Grok" (reasoning) and "Grok 4.5". `/model Grok 4.5`
// must select the full name, not treat "4.5" as an effort on "Grok".
let mut state = ModelState::default();
let (short_id, short_info) = model_with_reasoning("grok", "Grok");
let (long_id, long_info) = model_with_reasoning("grok-4.5", "Grok 4.5");
state.available.insert(short_id, short_info);
state.available.insert(long_id.clone(), long_info);
let mut ctx = dummy_exec_ctx(&state);
let result = ModelCommand.run(&mut ctx, "Grok 4.5");
match result {
CommandResult::Action(Action::SetDefaultModel(resolved_id)) => {
assert_eq!(resolved_id, long_id);
}
other => panic!("expected SetDefaultModel(Grok 4.5), got {other:?}"),
}
}
#[test]
fn run_rejects_effort_for_non_reasoning_model() {
let mut state = ModelState::default();
let (id, info) = plain_model("grok-4.5", "Grok 4.5");
state.available.insert(id, info);
let mut ctx = dummy_exec_ctx(&state);
let result = ModelCommand.run(&mut ctx, "Grok 4.5 high");
// Falls through to "is the whole string a model name?" — which
// it isn't, so we get an Unknown error.
assert!(matches!(result, CommandResult::Error(_)));
}
/// The bare `/model <name>` form dispatches
/// `Action::SetDefaultModel(<ModelId>)` instead of the legacy
/// `Action::SwitchModel { effort: None }`. The dispatcher routes
/// the typed setter through both `Effect::SwitchModel`
/// (session-level mutation) AND `Effect::PersistSetting`
/// (next-session default).
///
/// The payload is the typed `acp::ModelId` (resolved at the slash
/// boundary), not a String.
#[test]
fn run_bare_model_name_dispatches_set_default_model() {
let mut state = ModelState::default();
let (id, info) = plain_model("grok-4.5", "Grok 4.5");
state.available.insert(id.clone(), info);
let mut ctx = dummy_exec_ctx(&state);
let result = ModelCommand.run(&mut ctx, "Grok 4.5");
match result {
CommandResult::Action(Action::SetDefaultModel(resolved_id)) => {
assert_eq!(resolved_id, id);
}
other => panic!("expected Action::SetDefaultModel(<id>), got {other:?}"),
}
}
/// Case-insensitive matching against the catalog: `/model grok 4.5`
/// resolves to the same `ModelId` as `/model Grok 4.5`.
#[test]
fn run_set_default_model_resolves_case_insensitively() {
let mut state = ModelState::default();
let (id, info) = plain_model("grok-4.5", "Grok 4.5");
state.available.insert(id.clone(), info);
let mut ctx = dummy_exec_ctx(&state);
let result = ModelCommand.run(&mut ctx, "grok 4.5");
match result {
CommandResult::Action(Action::SetDefaultModel(resolved_id)) => {
assert_eq!(resolved_id, id);
}
other => panic!("expected Action::SetDefaultModel(<id>), got {other:?}"),
}
}
}

View file

@ -0,0 +1,133 @@
//! `/multiline` -- toggle multiline input mode.
//!
//! In multiline mode, Enter inserts a newline and Shift+Enter sends the
//! prompt (the inverse of normal mode). Empty-composer mid-turn Enter still
//! force-sends the top queued follow-up (send now), same as normal mode.
//! Toggled via `Ctrl+M`, this slash command, or the settings modal.
//!
//! Dispatches `Action::SetMultilineMode(!current)`. Per-session only
//! (no disk persistence).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Toggle multiline input mode via `/multiline`.
pub struct MultilineCommand;
impl SlashCommand for MultilineCommand {
fn name(&self) -> &str {
"multiline"
}
fn aliases(&self) -> &[&str] {
&["ml"]
}
fn description(&self) -> &str {
"Toggle multiline input mode (swap Enter and Shift+Enter)"
}
fn session_scoped(&self) -> bool {
true
}
fn offered_when_session_less(&self) -> bool {
// Dashboard dispatch/peek own their own multiline flag
// (`DashboardState::multiline_mode`); same swap as the agent prompt.
true
}
fn usage(&self) -> &str {
"/multiline"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let new = !ctx.pager_state.multiline_mode;
CommandResult::Action(Action::SetMultilineMode(new))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
fn make_ctx<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
multiline_mode: bool,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot {
multiline_mode,
yolo_mode: false,
..PagerLocalSnapshot::default()
},
}
}
/// Off → `SetMultilineMode(true)`.
#[test]
fn run_when_off_dispatches_set_to_true() {
let cmd = MultilineCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false);
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::Action(Action::SetMultilineMode(b)) => {
assert!(b, "off → should dispatch SetMultilineMode(true)");
}
other => panic!("expected Action::SetMultilineMode(true), got {other:?}"),
}
}
/// `/multiline` when on → dispatches `Action::SetMultilineMode(false)`.
#[test]
fn run_when_on_dispatches_set_to_false() {
let cmd = MultilineCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, true);
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::Action(Action::SetMultilineMode(b)) => {
assert!(!b, "on → should dispatch SetMultilineMode(false)");
}
other => panic!("expected Action::SetMultilineMode(false), got {other:?}"),
}
}
/// `/multiline` ignores args (no-arg command).
#[test]
fn run_ignores_args() {
let cmd = MultilineCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx(&models, &bundle, false);
let result = cmd.run(&mut ctx, "extra args ignored");
assert!(matches!(
result,
CommandResult::Action(Action::SetMultilineMode(true))
));
}
/// `/ml` alias resolves via registry.
#[test]
fn alias_ml_resolves_via_registry() {
use std::sync::Arc;
let reg = crate::slash::registry::CommandRegistry::new(vec![Arc::new(MultilineCommand)]);
let resolved = reg.get("ml").expect("/ml alias must resolve to a command");
assert_eq!(
resolved.name(),
"multiline",
"/ml alias must resolve to MultilineCommand"
);
}
}

View file

@ -0,0 +1,29 @@
//! `/new` (alias `/clear`) -- create a new session.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Start a new agent session, clearing the current conversation.
pub struct NewCommand;
impl SlashCommand for NewCommand {
fn name(&self) -> &str {
"new"
}
fn aliases(&self) -> &[&str] {
&["clear"]
}
fn description(&self) -> &str {
"Start a new session"
}
fn usage(&self) -> &str {
"/new"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::NewSession)
}
}

View file

@ -0,0 +1,30 @@
//! `/personas` -- open the agents modal on the Personas tab.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
use crate::views::agents_modal::AgentsTab;
/// Open the agents modal directly on the Personas tab.
pub struct PersonasCommand;
impl SlashCommand for PersonasCommand {
fn name(&self) -> &str {
"personas"
}
fn aliases(&self) -> &[&str] {
&[]
}
fn description(&self) -> &str {
"Manage personas (create, edit, delete)"
}
fn usage(&self) -> &str {
"/personas"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenConfigAgentsModal(Some(AgentsTab::Personas)))
}
}

View file

@ -0,0 +1,193 @@
//! `/plan` -- enter plan mode.
//!
//! `/plan` enters plan mode. `/plan <description>` enters plan mode and starts
//! a turn with the description after the mode switch completes.
//!
//! Use `/view-plan` to open the current saved plan preview.
use crate::app::actions::{Action, PlanModeKind};
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Enter plan mode.
pub struct PlanCommand;
impl SlashCommand for PlanCommand {
fn name(&self) -> &str {
"plan"
}
fn description(&self) -> &str {
"Enter plan mode"
}
fn session_scoped(&self) -> bool {
true
}
fn offered_when_session_less(&self) -> bool {
// The dashboard offers `/plan` to start the next spawned agent in
// plan mode (intercepted in `dispatch_dashboard_dispatch_slash`).
true
}
fn usage(&self) -> &str {
"/plan [description]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[description]")
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if trimmed.is_empty() {
return CommandResult::Action(Action::SetPlanMode(PlanModeKind::On));
}
CommandResult::Action(Action::EnterPlanMode {
description: Some(trimmed.to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
fn make_ctx_inactive_plan_mode<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot {
plan_mode_active: false,
..PagerLocalSnapshot::default()
},
}
}
fn make_ctx_active_plan_mode<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: PagerLocalSnapshot {
plan_mode_active: true,
..PagerLocalSnapshot::default()
},
}
}
/// `/plan` (no args, not in plan mode) → `SetPlanMode(On)`.
#[test]
fn no_args_not_in_plan_dispatches_set_plan_mode_on() {
let cmd = PlanCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx_inactive_plan_mode(&models, &bundle);
match cmd.run(&mut ctx, "") {
CommandResult::Action(Action::SetPlanMode(kind)) => {
assert_eq!(
kind,
PlanModeKind::On,
"`/plan` (no args, not in plan mode) must dispatch SetPlanMode(On)"
);
}
other => panic!("expected Action::SetPlanMode, got {other:?}"),
}
}
/// `/plan` (no args, already in plan mode) → idempotent `SetPlanMode(On)`.
#[test]
fn no_args_already_in_plan_dispatches_set_plan_mode_on() {
let cmd = PlanCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx_active_plan_mode(&models, &bundle);
match cmd.run(&mut ctx, "") {
CommandResult::Action(Action::SetPlanMode(kind)) => {
assert_eq!(kind, PlanModeKind::On);
}
other => panic!("expected Action::SetPlanMode, got {other:?}"),
}
}
/// Whitespace-only → treated as no args.
#[test]
fn whitespace_only_arg_not_in_plan_dispatches_set_plan_mode_on() {
let cmd = PlanCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx_inactive_plan_mode(&models, &bundle);
match cmd.run(&mut ctx, " ") {
CommandResult::Action(Action::SetPlanMode(kind)) => {
assert_eq!(kind, PlanModeKind::On);
}
other => panic!("expected SetPlanMode for whitespace-only arg, got {other:?}"),
}
}
/// `/plan <description>` → `EnterPlanMode` with description.
#[test]
fn with_description_keeps_enter_plan_mode_when_not_in_plan() {
let cmd = PlanCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx_inactive_plan_mode(&models, &bundle);
match cmd.run(&mut ctx, "Refactor the auth flow") {
CommandResult::Action(Action::EnterPlanMode { description }) => {
assert_eq!(
description.as_deref(),
Some("Refactor the auth flow"),
"`/plan <desc>` must dispatch EnterPlanMode with the description"
);
}
other => panic!("expected Action::EnterPlanMode, got {other:?}"),
}
}
/// `/plan <description>` when already in plan mode still emits
/// `EnterPlanMode`; the dispatcher owns the idempotent mode handling.
#[test]
fn with_description_already_in_plan_keeps_enter_plan_mode() {
let cmd = PlanCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx_active_plan_mode(&models, &bundle);
match cmd.run(&mut ctx, "something") {
CommandResult::Action(Action::EnterPlanMode { description }) => {
assert_eq!(description.as_deref(), Some("something"));
}
other => panic!("expected EnterPlanMode, got {other:?}"),
}
}
/// Whitespace is trimmed from the description.
#[test]
fn with_description_trims_whitespace() {
let cmd = PlanCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = make_ctx_inactive_plan_mode(&models, &bundle);
match cmd.run(&mut ctx, " hello world ") {
CommandResult::Action(Action::EnterPlanMode { description }) => {
assert_eq!(description.as_deref(), Some("hello world"));
}
other => panic!("expected EnterPlanMode, got {other:?}"),
}
}
}

View file

@ -0,0 +1,106 @@
//! `/hooks` and `/plugins` -- open the hooks/plugins modal.
//!
//! These commands always open the tabbed modal. All hook/plugin management
//! (install, uninstall, trust, etc.) is done through the modal's UI — no
//! subcommands are passed through to the shell via the slash command.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
use crate::views::extensions_modal::ExtensionsTab;
use xai_grok_telemetry::events::ExtensionsModalTrigger;
/// Open the hooks/plugins modal on the Hooks tab.
pub struct HooksCommand;
impl SlashCommand for HooksCommand {
fn name(&self) -> &str {
"hooks"
}
fn description(&self) -> &str {
"View hooks"
}
fn usage(&self) -> &str {
"/hooks"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenExtensionsModal {
tab: ExtensionsTab::Hooks,
trigger: ExtensionsModalTrigger::SlashCommand,
})
}
}
/// Open the hooks/plugins modal on the Plugins tab.
pub struct PluginsCommand;
impl SlashCommand for PluginsCommand {
fn name(&self) -> &str {
"plugins"
}
fn description(&self) -> &str {
"View plugins"
}
fn usage(&self) -> &str {
"/plugins"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenExtensionsModal {
tab: ExtensionsTab::Plugins,
trigger: ExtensionsModalTrigger::SlashCommand,
})
}
}
/// Open the hooks/plugins modal on the Marketplace tab.
pub struct MarketplaceCommand;
impl SlashCommand for MarketplaceCommand {
fn name(&self) -> &str {
"marketplace"
}
fn description(&self) -> &str {
"View marketplace"
}
fn usage(&self) -> &str {
"/marketplace"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenExtensionsModal {
tab: ExtensionsTab::Marketplace,
trigger: ExtensionsModalTrigger::SlashCommand,
})
}
}
/// Open the hooks/plugins modal on the Skills tab.
pub struct SkillsCommand;
impl SlashCommand for SkillsCommand {
fn name(&self) -> &str {
"skills"
}
fn description(&self) -> &str {
"View skills"
}
fn usage(&self) -> &str {
"/skills"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenExtensionsModal {
tab: ExtensionsTab::Skills,
trigger: ExtensionsModalTrigger::SlashCommand,
})
}
}

View file

@ -0,0 +1,212 @@
//! `/privacy` -- show or toggle privacy and data retention status.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show or toggle privacy and data retention status.
///
/// Usage:
/// - `/privacy` show current status
/// - `/privacy opt-in` opt in to coding data sharing
/// - `/privacy opt-out` opt out of coding data sharing
///
/// Case-insensitive. Only unambiguous aliases are accepted (e.g. `in`,
/// `share`, `out`, `private`) — generic toggles like `on`/`off` are
/// rejected because they're ambiguous in privacy context.
pub struct PrivacyCommand;
impl SlashCommand for PrivacyCommand {
fn name(&self) -> &str {
"privacy"
}
fn description(&self) -> &str {
"Show or toggle privacy & data retention status"
}
fn usage(&self) -> &str {
"/privacy [opt-in|opt-out]"
}
fn takes_args(&self) -> bool {
true
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let arg = args.trim();
if arg.is_empty() {
return CommandResult::Action(Action::ShowPrivacyInfo);
}
match parse_privacy_arg(arg) {
Some(opted_in) => CommandResult::Action(Action::SetCodingDataSharing { opted_in }),
None => CommandResult::Error(format!(
"Unknown argument `{arg}`. Valid options: opt-in (aliases: in, share) | \
opt-out (aliases: out, private)."
)),
}
}
}
/// Parse `/privacy <arg>` into `Some(true)` (opt-in), `Some(false)`
/// (opt-out), or `None` (unknown). Case-insensitive ASCII matching.
#[doc(hidden)]
pub fn parse_privacy_arg(arg: &str) -> Option<bool> {
const OPT_IN_ALIASES: &[&str] = &["opt-in", "in", "share"];
const OPT_OUT_ALIASES: &[&str] = &["opt-out", "out", "private"];
if OPT_IN_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
return Some(true);
}
if OPT_OUT_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
return Some(false);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_opt_in_canonical() {
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
}
#[test]
fn parse_opt_out_canonical() {
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
}
/// Case-insensitive matching.
#[test]
fn parse_case_insensitive() {
for variant in &["OPT-IN", "Opt-In", "opt-IN", "OpT-iN"] {
assert_eq!(
parse_privacy_arg(variant),
Some(true),
"case-insensitive parse must accept `{variant}` as opt-in",
);
}
for variant in &["OPT-OUT", "Opt-Out", "opt-OUT", "OpT-oUt"] {
assert_eq!(
parse_privacy_arg(variant),
Some(false),
"case-insensitive parse must accept `{variant}` as opt-out",
);
}
}
/// Pins the accepted alias catalog.
#[test]
fn parse_opt_in_aliases() {
for alias in &["in", "share"] {
assert_eq!(
parse_privacy_arg(alias),
Some(true),
"alias `{alias}` must map to opt-in",
);
}
}
#[test]
fn parse_opt_out_aliases() {
for alias in &["out", "private"] {
assert_eq!(
parse_privacy_arg(alias),
Some(false),
"alias `{alias}` must map to opt-out",
);
}
}
/// Ambiguous generic-toggle aliases must be rejected — `/privacy on`
/// is ambiguous (could mean opt-in or opt-out).
#[test]
fn parse_rejects_ambiguous_generic_aliases() {
for ambiguous in &[
"on", "off", "true", "false", "enable", "enabled", "disable", "disabled",
] {
assert_eq!(
parse_privacy_arg(ambiguous),
None,
"ambiguous alias `{ambiguous}` MUST be rejected — it would let a user typing \
`/privacy {ambiguous}` get the OPPOSITE of their intent in privacy context. \
See Security Issue 10 in PR 9 R1.",
);
}
}
/// Unknown arguments return None → the command surfaces an error
/// listing valid options. Pins the "no silent fallback" contract.
#[test]
fn parse_unknown_returns_none() {
for unknown in &["yes", "no", "maybe", "opt-maybe", "", " ", "1", "0"] {
assert_eq!(
parse_privacy_arg(unknown),
None,
"unknown arg `{unknown}` must NOT parse",
);
}
}
/// Alias families must not overlap.
#[test]
fn alias_families_disjoint() {
let opt_in_results: Vec<bool> = ["opt-in", "in", "share"]
.iter()
.map(|a| parse_privacy_arg(a).unwrap())
.collect();
assert!(
opt_in_results.iter().all(|b| *b),
"every opt-in alias must parse to true",
);
let opt_out_results: Vec<bool> = ["opt-out", "out", "private"]
.iter()
.map(|a| parse_privacy_arg(a).unwrap())
.collect();
assert!(
opt_out_results.iter().all(|b| !*b),
"every opt-out alias must parse to false",
);
}
/// Error message must list every accepted alias.
#[test]
fn error_message_lists_all_accepted_aliases() {
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
let cmd = PrivacyCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
let result = cmd.run(&mut ctx, "garbage-input");
match result {
CommandResult::Error(msg) => {
// Every accepted alias appears in the error message.
for alias in &["opt-in", "in", "share", "opt-out", "out", "private"] {
assert!(
msg.contains(alias),
"error message must mention alias `{alias}` so the user knows \
what to type; msg = {msg:?}",
);
}
// Dropped ambiguous aliases must not appear.
for dropped in &["off", "true", "false", "enable", "disable"] {
assert!(
!msg.contains(dropped),
"dropped alias `{dropped}` must NOT appear in error message \
(would suggest it's still accepted); msg = {msg:?}",
);
}
}
other => panic!("expected Error result for unknown arg, got {other:?}"),
}
}
}

View file

@ -0,0 +1,90 @@
//! `/queue` -- list the queued prompts as a committed system block.
//!
//! Minimal mode has no interactive `QueuePane`, so `/queue` is the way to
//! inspect what's waiting behind the running turn. It works in every
//! render mode. The dispatcher (`dispatch_show_queue`) reads the merged
//! server + local queue and commits a read-only list; editing the queue is
//! out of scope here (use the queue pane in the full TUI).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// List the queued prompts.
pub struct QueueCommand;
impl SlashCommand for QueueCommand {
fn name(&self) -> &str {
"queue"
}
fn description(&self) -> &str {
"List the prompts queued behind the running turn"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/queue"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_string());
}
CommandResult::Action(Action::ShowQueue)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn ctx_with_session(models: &ModelState, sid: Option<&agent_client_protocol::SessionId>) {
let mut ctx = CommandExecCtx {
models,
session_id: sid,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
pager_state: PagerLocalSnapshot::default(),
};
match (QueueCommand.run(&mut ctx, ""), sid.is_some()) {
(CommandResult::Action(Action::ShowQueue), true) => {}
(CommandResult::Error(msg), false) => assert!(msg.contains("No active session")),
(other, has) => panic!("unexpected result {other:?} for has_session={has}"),
}
}
#[test]
fn no_session_errors() {
let models = ModelState::default();
ctx_with_session(&models, None);
}
#[test]
fn with_session_dispatches_show_queue() {
let models = ModelState::default();
let sid = agent_client_protocol::SessionId::from("s1".to_string());
ctx_with_session(&models, Some(&sid));
}
#[test]
fn available_in_minimal_by_default() {
assert!(QueueCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,33 @@
//! `/recap` -- summarize the session so far ("where was I").
//!
//! Returns `CommandResult::Action(Action::SendRecap { auto: false })` so the
//! dispatch layer fires it as an ACP ext method (`x.ai/recap`) that bypasses
//! the prompt queue. The recap arrives asynchronously as a scrollback line and
//! is never added to the model conversation.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct RecapCommand;
impl SlashCommand for RecapCommand {
fn name(&self) -> &str {
"recap"
}
fn description(&self) -> &str {
"Summarize the session so far"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/recap"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::SendRecap { auto: false })
}
}

View file

@ -0,0 +1,60 @@
//! `/release-notes` -- view release notes for the current version.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show release notes for the current pager version.
pub struct ReleaseNotesCommand;
impl SlashCommand for ReleaseNotesCommand {
fn name(&self) -> &str {
"release-notes"
}
fn aliases(&self) -> &[&str] {
&["changelog"]
}
fn description(&self) -> &str {
"View release notes for the current version"
}
fn usage(&self) -> &str {
"/release-notes"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let changelog = xai_grok_shell::util::changelog::ChangelogManager::new().fetch();
match changelog.markdown {
Some(content) => CommandResult::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: content.trim().to_string(),
}),
None => CommandResult::Error("No release notes available (offline).".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_notes_metadata() {
let cmd = ReleaseNotesCommand;
assert_eq!(cmd.name(), "release-notes");
assert_eq!(cmd.aliases(), &["changelog"]);
assert!(!cmd.takes_args());
}
#[test]
fn release_notes_returns_action_or_error() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ReleaseNotesCommand.run(&mut ctx, "");
assert!(
matches!(result, CommandResult::Action(_) | CommandResult::Error(_)),
"expected Action or Error, got {result:?}"
);
}
}

View file

@ -0,0 +1,38 @@
//! `/remember` -- save a memory note.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Save a memory note inline or enter remember mode.
pub struct RememberCommand;
impl SlashCommand for RememberCommand {
fn name(&self) -> &str {
"remember"
}
fn description(&self) -> &str {
"Save a memory note"
}
fn usage(&self) -> &str {
"/remember [text]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("[memory note text]")
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
if trimmed.is_empty() {
CommandResult::Action(Action::EnterRememberMode)
} else {
CommandResult::Action(Action::SendRememberNote(trimmed.to_string()))
}
}
}

View file

@ -0,0 +1,54 @@
//! `/rename` (alias `/title`) -- rename the current session.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Rename the current session's title/summary.
pub struct RenameCommand;
impl SlashCommand for RenameCommand {
fn name(&self) -> &str {
"rename"
}
fn aliases(&self) -> &[&str] {
&["title"]
}
fn description(&self) -> &str {
"Rename the current session"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/rename <title>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("<title>")
}
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_string());
}
let title = args.trim().to_string();
if title.is_empty() {
return CommandResult::Error("Usage: /rename <new title>".to_string());
}
CommandResult::Action(Action::RenameSession { title })
}
}

View file

@ -0,0 +1,24 @@
//! `/resume` -- open session picker overlay to resume a previous session.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct ResumeCommand;
impl SlashCommand for ResumeCommand {
fn name(&self) -> &str {
"resume"
}
fn description(&self) -> &str {
"Resume a previous session"
}
fn usage(&self) -> &str {
"/resume"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::ShowSessionPicker)
}
}

View file

@ -0,0 +1,26 @@
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct RewindCommand;
impl SlashCommand for RewindCommand {
fn name(&self) -> &str {
"rewind"
}
fn description(&self) -> &str {
"Rewind to a previous turn"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/rewind"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::RewindShowPicker)
}
}

View file

@ -0,0 +1,226 @@
//! `/minimal` and `/fullscreen` — session-scoped re-exec of the active session.
use crate::app::ScreenMode;
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand};
/// Reopen the active session in the other screen mode (`/minimal` ⇄ `/fullscreen`).
pub struct ScreenModeSwitchCommand {
/// `true` → `/minimal` (fullscreen → scrollback-native);
/// `false` → `/fullscreen` (minimal → alt-screen TUI).
to_minimal: bool,
}
impl ScreenModeSwitchCommand {
/// `/minimal`: offered in fullscreen, relaunches with `--minimal`.
pub const fn minimal() -> Self {
Self { to_minimal: true }
}
/// `/fullscreen` (alias `/full`): offered in minimal, relaunches without
/// `--minimal`.
pub const fn fullscreen() -> Self {
Self { to_minimal: false }
}
/// The mode this command switches *away from* — the only mode it is
/// offered in (switching to the mode you are already in is meaningless).
fn source_mode_active(&self, mode: ScreenMode) -> bool {
if self.to_minimal {
mode.is_fullscreen()
} else {
mode.is_minimal()
}
}
fn target_label(&self) -> &'static str {
if self.to_minimal {
"minimal"
} else {
"fullscreen"
}
}
fn source_label(&self) -> &'static str {
if self.to_minimal {
"fullscreen"
} else {
"minimal"
}
}
}
impl SlashCommand for ScreenModeSwitchCommand {
fn name(&self) -> &str {
self.target_label()
}
fn aliases(&self) -> &[&str] {
if self.to_minimal { &[] } else { &["full"] }
}
fn description(&self) -> &str {
if self.to_minimal {
"Reopen this session in minimal (scrollback-native) mode — switch back with /fullscreen"
} else {
"Reopen this session in fullscreen mode — switch back with /minimal"
}
}
fn usage(&self) -> &str {
if self.to_minimal {
"/minimal"
} else {
"/fullscreen"
}
}
fn session_scoped(&self) -> bool {
true
}
/// `/minimal` switches *away from* fullscreen, so it is pointless inside
/// minimal; `/fullscreen` is the way back out.
fn available_in_minimal(&self) -> bool {
!self.to_minimal
}
/// Only offered while the mode being switched away from is active.
fn visible(&self, ctx: &AppCtx) -> bool {
self.source_mode_active(ctx.screen_mode)
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if !self.source_mode_active(ctx.screen_mode) {
return CommandResult::Error(format!(
"/{} is only available in {} mode",
self.target_label(),
self.source_label(),
));
}
if ctx.session_id.is_none() {
return CommandResult::Error(format!(
"No active session to reopen in {} mode",
self.target_label(),
));
}
CommandResult::Action(Action::RelaunchInScreenMode {
minimal: self.to_minimal,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
fn app_ctx<'a>(models: &'a ModelState, mode: ScreenMode) -> AppCtx<'a> {
AppCtx {
models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: mode,
}
}
fn exec_ctx<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
mode: ScreenMode,
session: Option<&'a agent_client_protocol::SessionId>,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: session,
bundle_state: bundle,
screen_mode: mode,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}
#[test]
fn minimal_visible_only_in_fullscreen() {
let models = ModelState::default();
let cmd = ScreenModeSwitchCommand::minimal();
assert!(cmd.visible(&app_ctx(&models, ScreenMode::Fullscreen)));
assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Minimal)));
assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Inline)));
}
#[test]
fn fullscreen_visible_only_in_minimal() {
let models = ModelState::default();
let cmd = ScreenModeSwitchCommand::fullscreen();
assert!(cmd.visible(&app_ctx(&models, ScreenMode::Minimal)));
assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Fullscreen)));
assert!(!cmd.visible(&app_ctx(&models, ScreenMode::Inline)));
}
#[test]
fn run_returns_relaunch_action_with_session() {
let models = ModelState::default();
let bundle = BundleState::default();
let sid = agent_client_protocol::SessionId::from("sess-abc".to_string());
let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Fullscreen, Some(&sid));
assert!(matches!(
ScreenModeSwitchCommand::minimal().run(&mut ctx, ""),
CommandResult::Action(Action::RelaunchInScreenMode { minimal: true })
));
let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Minimal, Some(&sid));
assert!(matches!(
ScreenModeSwitchCommand::fullscreen().run(&mut ctx, ""),
CommandResult::Action(Action::RelaunchInScreenMode { minimal: false })
));
}
#[test]
fn run_errors_without_session() {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Fullscreen, None);
assert!(matches!(
ScreenModeSwitchCommand::minimal().run(&mut ctx, ""),
CommandResult::Error(msg) if msg.contains("No active session")
));
let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Minimal, None);
assert!(matches!(
ScreenModeSwitchCommand::fullscreen().run(&mut ctx, ""),
CommandResult::Error(msg) if msg.contains("No active session")
));
}
#[test]
fn run_errors_outside_source_mode() {
let models = ModelState::default();
let bundle = BundleState::default();
let sid = agent_client_protocol::SessionId::from("sess-abc".to_string());
// `/minimal` outside fullscreen.
let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Inline, Some(&sid));
assert!(matches!(
ScreenModeSwitchCommand::minimal().run(&mut ctx, ""),
CommandResult::Error(msg) if msg.contains("fullscreen")
));
// `/fullscreen` outside minimal.
let mut ctx = exec_ctx(&models, &bundle, ScreenMode::Fullscreen, Some(&sid));
assert!(matches!(
ScreenModeSwitchCommand::fullscreen().run(&mut ctx, ""),
CommandResult::Error(msg) if msg.contains("minimal mode")
));
}
#[test]
fn minimal_availability_mirrors_direction() {
// `/minimal` is a fullscreen-pane switcher; `/fullscreen` is the way
// back out of minimal.
assert!(!ScreenModeSwitchCommand::minimal().available_in_minimal());
assert!(ScreenModeSwitchCommand::fullscreen().available_in_minimal());
}
}

View file

@ -0,0 +1,41 @@
//! `/scroll-debug` — toggle the scroll-diagnostics HUD
//! ([`crate::views::scroll_debug_hud`]).
//!
//! Hidden diagnostic (the `/gboom` pattern): typeable but never listed in
//! the dropdown, and any argument passes through like an unknown command.
//! Pairs with `GROK_SCROLL_DEBUG=1`, which enables the HUD from startup;
//! this command flips it live mid-session.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand};
/// Hidden toggle for the scroll-debug HUD.
pub struct ScrollDebugCommand;
impl SlashCommand for ScrollDebugCommand {
fn name(&self) -> &str {
"scroll-debug"
}
fn description(&self) -> &str {
// Never shown: the command is hidden from the dropdown.
"Toggle the scroll-diagnostics HUD"
}
fn usage(&self) -> &str {
"/scroll-debug"
}
/// Diagnostic: typeable, never listed.
fn visible(&self, _ctx: &AppCtx) -> bool {
false
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
if !args.trim().is_empty() {
// With arguments, behave as if the command didn't exist.
return CommandResult::PassThrough(format!("/scroll-debug {args}"));
}
CommandResult::Action(Action::ToggleScrollDebugHud)
}
}

View file

@ -0,0 +1,34 @@
//! `/session-info` -- show current session info (instant, not queued).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show session info (session ID, cwd, model, context usage).
pub struct SessionInfoCommand;
impl SlashCommand for SessionInfoCommand {
fn name(&self) -> &str {
"session-info"
}
fn description(&self) -> &str {
"Show session info"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/session-info"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
// Check if we have an active session
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_string());
}
CommandResult::Action(Action::ShowSessionInfo)
}
}

View file

@ -0,0 +1,98 @@
//! `/settings` -- open the settings modal.
//!
//! No `/settings <id>` direct-jump — args are silently discarded and
//! the modal always opens. Use the in-modal `/` filter to search.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open the settings modal.
pub struct SettingsCommand;
impl SlashCommand for SettingsCommand {
fn name(&self) -> &str {
"settings"
}
fn aliases(&self) -> &[&str] {
&["config", "preferences", "prefs"]
}
fn description(&self) -> &str {
"Open the settings modal"
}
fn usage(&self) -> &str {
"/settings"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenSettings)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx<'a>(models: &'a ModelState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn empty_args_dispatches_open_settings() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = SettingsCommand;
let result = cmd.run(&mut ctx, "");
assert!(
matches!(result, CommandResult::Action(Action::OpenSettings)),
"expected OpenSettings, got {result:?}",
);
}
/// Args are silently discarded — modal always opens.
#[test]
fn args_still_dispatches_open_settings() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
let cmd = SettingsCommand;
for args in ["theme", " ", "anything goes", "compact-mode"] {
let result = cmd.run(&mut ctx, args);
assert!(
matches!(result, CommandResult::Action(Action::OpenSettings)),
"expected OpenSettings for args={args:?}, got {result:?}",
);
}
}
#[test]
fn aliases_are_registered() {
let cmd = SettingsCommand;
assert_eq!(cmd.name(), "settings");
assert_eq!(cmd.aliases(), &["config", "preferences", "prefs"]);
}
}

View file

@ -0,0 +1,34 @@
//! `/share` -- share current session via URL.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Share the current session via a public URL.
pub struct ShareCommand;
impl SlashCommand for ShareCommand {
fn name(&self) -> &str {
"share"
}
fn description(&self) -> &str {
"Share this session via URL"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/share"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
// Check if we have an active session
if ctx.session_id.is_none() {
return CommandResult::Error("No active session to share".to_string());
}
CommandResult::Action(Action::ShareSession)
}
}

View file

@ -0,0 +1,91 @@
//! `/tasks` -- list background tasks, subagents, and scheduled tasks.
//!
//! Minimal mode has no interactive `TasksPane`, so `/tasks` is the way
//! to snapshot what's running in the background. It works in every render mode.
//! The dispatcher (`dispatch_show_tasks`) reads the three task sources and
//! commits a read-only list; killing/attaching is out of scope here (use the
//! tasks pane in the full TUI).
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// List background tasks, subagents, and scheduled tasks.
pub struct TasksCommand;
impl SlashCommand for TasksCommand {
fn name(&self) -> &str {
"tasks"
}
fn description(&self) -> &str {
"List background tasks, subagents, and scheduled tasks"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/tasks"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_string());
}
CommandResult::Action(Action::ShowTasks)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn run_with_session(sid: Option<&agent_client_protocol::SessionId>) -> CommandResult {
let models = ModelState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: sid,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
pager_state: PagerLocalSnapshot::default(),
};
TasksCommand.run(&mut ctx, "")
}
#[test]
fn no_session_errors() {
match run_with_session(None) {
CommandResult::Error(msg) => assert!(msg.contains("No active session")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn with_session_dispatches_show_tasks() {
let sid = agent_client_protocol::SessionId::from("s1".to_string());
assert!(matches!(
run_with_session(Some(&sid)),
CommandResult::Action(Action::ShowTasks)
));
}
#[test]
fn available_in_minimal_by_default() {
assert!(TasksCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,169 @@
//! `/terminal-setup` — diagnose terminal, color/theme, and clipboard setup.
//!
//! Runs the same diagnostics engine used for startup warnings and formats
//! the results as a user-readable message. This gives users an on-demand
//! way to check their environment and see fix instructions.
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
use crate::terminal::TerminalName;
pub struct TerminalSetupCommand;
impl SlashCommand for TerminalSetupCommand {
fn name(&self) -> &str {
"terminal-setup"
}
fn aliases(&self) -> &[&str] {
&["terminal-check", "terminal-info"]
}
fn description(&self) -> &str {
"Check terminal, color, and clipboard setup"
}
fn usage(&self) -> &str {
"/terminal-setup"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let ctx = crate::terminal::terminal_context();
let query = crate::diagnostics::LiveTmuxQuery;
let is_control_mode = crate::terminal::detect_tmux_control_mode(ctx);
let mut warnings = crate::diagnostics::collect_startup_warnings(
ctx,
&query,
is_control_mode,
_ctx.screen_mode.is_fullscreen(),
);
// Live-environment check, kept out of `collect_startup_warnings` so
// its tests stay hermetic (same pattern as the WezTerm warning below).
warnings.extend(crate::diagnostics::diagnose_wayland_data_control_live());
// WezTerm without the Kitty keyboard protocol: surface the fix
// alongside the other issues. By the time the user runs
// /terminal-setup the async XTVERSION reply has landed, so this
// also catches WezTerm over SSH (env brand Unknown, self-report
// "WezTerm <version>").
let wezterm_warning = crate::diagnostics::wezterm_kitty_keyboard_warning(
ctx,
crate::app::kitty_flags_pushed(),
crate::terminal::xtversion::detected(),
);
let wezterm_kkp_off = wezterm_warning.is_some();
warnings.extend(wezterm_warning);
// Color not in collect_startup_warnings (noisy on limited terminals).
let color_level = crate::theme::color_support::get();
warnings.extend(crate::diagnostics::color_support_warning(
color_level,
ctx.brand,
ctx.is_tmux_backed(),
&ctx.tmux_config_path(),
));
let route = crate::clipboard::clipboard_route();
let is_ssh = xai_grok_shell::util::clipboard::is_remote_session();
let mut out = String::new();
// -- Environment --
out.push_str("Environment\n");
out.push_str(&format!(" terminal {}\n", ctx.brand));
if let Some(v) = crate::terminal::xtversion::detected() {
out.push_str(&format!(" xtversion {}\n", v));
}
out.push_str(&format!(" multiplexer {}\n", ctx.multiplexer));
if let Some(ref byobu) = ctx.byobu {
out.push_str(&format!(" byobu {}\n", byobu));
}
out.push_str(&format!(
" ssh {}\n",
if is_ssh { "yes" } else { "no" }
));
out.push_str(&crate::diagnostics::format_color_env_line(color_level));
out.push_str(&crate::diagnostics::format_themes_env_line(color_level));
let kb = ctx.keyboard_capabilities();
if kb.modifier_delivery.benefits_from_rescue() || kb.enter_needs_rescue() {
let rescue = if cfg!(target_os = "macos") {
"OS rescue active"
} else {
"OS rescue unavailable on this platform"
};
out.push_str(&format!(
" keyboard {} ({})\n",
kb.modifier_delivery.label(),
rescue
));
}
// Some terminals can't distinguish Shift+Enter from bare Enter at
// the byte level because the Kitty keyboard protocol isn't
// negotiated (VTE < 0.82, or VS Code's xterm.js which mis-encodes
// shifted keys). Point users at Alt+Enter, which is reliably
// delivered as ESC+CR. Suppressed when the WezTerm warning fired:
// stock WezTerm binds Alt+Enter to ToggleFullScreen, so advertising
// it would contradict that warning's `\`+Enter guidance.
if ctx.shift_enter_unavailable() && !wezterm_kkp_off {
let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte {
match ctx.vte_version.as_deref() {
Some(v) => format!("VTE {v}; need >= 8200 for Shift+Enter"),
None => "legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned(),
}
} else if matches!(
ctx.brand,
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed
) {
format!("{}: xterm.js can't distinguish Shift+Enter", ctx.brand)
} else {
"no Kitty keyboard protocol; Shift+Enter == Enter".to_owned()
};
out.push_str(&format!(" newline Alt+Enter ({detail})\n"));
}
// -- Clipboard --
out.push_str("\nClipboard routes\n");
out.push_str(&format!(
" native {} (tool: {})\n",
if route.native { "active" } else { "off" },
xai_grok_shell::util::clipboard::native_tool_name(),
));
out.push_str(&format!(
" tmux buffer {}\n",
if route.tmux_buffer { "active" } else { "off" }
));
out.push_str(&format!(
" osc 52 {}\n",
if route.osc52 { "active" } else { "off" }
));
out.push_str(&format!(
" data-control {}\n",
crate::clipboard::wayland_data_control_label()
));
// -- Diagnostics --
if warnings.is_empty() {
out.push_str("\nNo issues found.\n");
} else {
out.push_str(&format!("\n{} issue(s)\n", warnings.len()));
for w in &warnings {
out.push_str(&format!("\n [!] {}\n", w.message));
match (w.fix.as_deref(), w.config_path.as_deref()) {
(Some(fix), Some(path)) => {
out.push_str(&format!(" Fix: place `{}` in {}\n", fix, path));
}
(Some(fix), None) => {
out.push_str(&format!(" Fix: run `{}`\n", fix));
}
_ => {}
}
if let Some(note) = w.note.as_deref() {
out.push_str(&format!(" Note: {}\n", note));
}
}
}
CommandResult::Message(out)
}
}

View file

@ -0,0 +1,551 @@
//! `/theme` (alias `/t`) -- switch the color theme.
//!
//! Toggles between available themes or switches to a named theme.
//! Selecting `auto` enables system-appearance-driven theme switching.
//! Selecting an explicit theme disengages auto mode.
//!
//! `run` dispatches `Action::SetTheme(<canonical>)` — the dispatcher
//! handles mutation + persistence + toast. `preview_arg` /
//! `cancel_preview` call `Theme::apply_kind` directly for non-persisting
//! visual previews (no toast/disk writes per keystroke).
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
use crate::theme::{Theme, ThemeKind, cache as theme_cache};
/// Switch the pager color theme.
pub struct ThemeCommand;
impl SlashCommand for ThemeCommand {
fn name(&self) -> &str {
"theme"
}
fn aliases(&self) -> &[&str] {
&["t"]
}
fn description(&self) -> &str {
"Switch the color theme"
}
/// Minimal has no theming, so there is nothing for `/theme` to switch.
fn available_in_minimal(&self) -> bool {
false
}
fn usage(&self) -> &str {
"/theme <name>"
}
fn takes_args(&self) -> bool {
true
}
fn args_required(&self) -> bool {
false
}
fn arg_placeholder(&self) -> Option<&str> {
Some("<theme>")
}
fn supports_preview(&self) -> bool {
true
}
fn preview_state(&self) -> Option<String> {
Some(Theme::current_kind().display_name().to_string())
}
fn preview_arg(&self, arg: &str) {
if let Some(kind) = ThemeKind::from_name(arg) {
if kind.is_auto() {
// Preview the theme that auto mode would resolve to.
let resolved = theme_cache::resolve_auto();
Theme::apply_kind(resolved);
} else {
Theme::apply_kind(kind);
}
}
}
fn cancel_preview(&self, previous: &str) {
if let Some(kind) = ThemeKind::from_name(previous) {
Theme::apply_kind(kind);
}
}
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
let current = Theme::current_kind();
let is_auto = theme_cache::is_auto_mode();
let available = ThemeKind::available();
// Prepend "auto" (follow system appearance) as the first option.
let auto_active = if is_auto { " (active)" } else { "" };
let mut items = vec![ArgItem {
display: "auto".to_string(),
match_text: "auto".to_string(),
insert_text: "auto".to_string(),
description: format!("auto (follow system){auto_active}"),
}];
// Concrete themes — only show "(active)" when not in auto mode.
items.extend(available.iter().map(|kind| {
let active = if *kind == current && !is_auto {
" (active)"
} else {
""
};
ArgItem {
display: kind.display_name().to_string(),
match_text: kind.display_name().to_string(),
insert_text: kind.display_name().to_string(),
description: format!("{}{active}", kind.display_name()),
}
}));
Some(items)
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let trimmed = args.trim();
let available = ThemeKind::available();
// No args: toggle between available themes.
if trimmed.is_empty() {
let current = Theme::current_kind();
let current_idx = available.iter().position(|k| *k == current).unwrap_or(0);
let next = available[(current_idx + 1) % available.len()];
return CommandResult::Action(Action::SetTheme(next.display_name().to_string()));
}
// Named theme (including "auto"): parse and dispatch.
// Truecolor-only themes are accepted regardless of terminal —
// `Theme::apply_kind` clamps the live visual as needed.
match ThemeKind::from_name(trimmed) {
Some(kind) => {
// Normalise alias to canonical display_name.
CommandResult::Action(Action::SetTheme(kind.display_name().to_string()))
}
None => {
let all_names: Vec<&str> =
ThemeKind::ALL.iter().map(|k| k.display_name()).collect();
CommandResult::Error(format!(
"Unknown theme: {}. Available: auto, {}",
trimmed,
all_names.join(", ")
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{cache as theme_cache, system_appearance};
/// Run a test with a clean in-memory state. Prevents disk reads by
/// pre-loading the theme state.
fn with_test_env(f: impl FnOnce()) {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
theme_cache::reset_for_test();
theme_cache::seed_auto_theme_defaults_for_test();
system_appearance::clear_mock();
// Set LOADED=true so current_kind() doesn't try to read from disk.
theme_cache::set(ThemeKind::GrokNight);
f();
system_appearance::clear_mock();
theme_cache::reset_for_test();
}
#[test]
fn theme_unavailable_in_minimal() {
assert!(!ThemeCommand.available_in_minimal());
}
// -- suggest_args ---------------------------------------------------------
#[test]
fn suggest_args_prepends_auto_option() {
with_test_env(|| {
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
assert_eq!(items[0].insert_text, "auto");
assert!(items[0].description.contains("follow system"));
// auto + all available concrete themes
assert_eq!(items.len(), ThemeKind::available().len() + 1);
});
}
#[test]
fn suggest_args_auto_active_when_auto_mode() {
with_test_env(|| {
theme_cache::set_auto_mode(true);
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
assert!(
items[0].description.contains("(active)"),
"auto should show (active), got: {}",
items[0].description
);
});
}
#[test]
fn suggest_args_auto_not_active_when_explicit() {
with_test_env(|| {
theme_cache::set_auto_mode(false);
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
assert!(
!items[0].description.contains("(active)"),
"auto should not show (active), got: {}",
items[0].description
);
});
}
#[test]
fn suggest_args_explicit_active_when_not_auto() {
with_test_env(|| {
theme_cache::set_auto_mode(false);
theme_cache::set(ThemeKind::GrokNight);
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
let groknight = items
.iter()
.find(|i| i.insert_text == "groknight")
.expect("groknight should be in list");
assert!(
groknight.description.contains("(active)"),
"explicit theme should show (active), got: {}",
groknight.description
);
});
}
#[test]
fn suggest_args_no_explicit_active_when_auto() {
with_test_env(|| {
theme_cache::set_auto_mode(true);
theme_cache::set(ThemeKind::GrokNight);
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
// No concrete theme should show "(active)" in auto mode.
for item in items.iter().skip(1) {
assert!(
!item.description.contains("(active)"),
"{} should not show (active) in auto mode",
item.insert_text
);
}
});
}
// -- run (dispatches Action::SetTheme) ------------------------------------
/// `/theme <name>` returns `Action::SetTheme(<canonical>)` —
/// the dispatcher handles in-memory state + disk write + toast.
#[test]
fn run_explicit_dispatches_set_theme_action() {
with_test_env(|| {
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let bundle = crate::app::bundle::BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
let result = cmd.run(&mut ctx, "groknight");
match result {
CommandResult::Action(Action::SetTheme(name)) => {
assert_eq!(name, "groknight");
}
other => panic!("expected Action::SetTheme(\"groknight\"), got {other:?}"),
}
});
}
/// `/theme` (no args) toggles by dispatching `Action::SetTheme(<next>)`.
/// Precondition-assert that `ThemeKind::available()` has ≥2 entries;
/// otherwise the previous `unwrap_or` masked a broken upstream
/// invariant.
#[test]
fn run_toggle_dispatches_set_theme_action() {
with_test_env(|| {
theme_cache::set(ThemeKind::GrokNight);
// Hard-fail with a clear message if the precondition
// breaks — `(0 + 1) % 0` in `run` would otherwise panic
// with `attempt to calculate the remainder with a
// divisor of zero`, which is a worse error message.
assert!(
ThemeKind::available().len() >= 2,
"toggle test requires ≥2 available themes, got {}",
ThemeKind::available().len(),
);
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let bundle = crate::app::bundle::BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
let result = cmd.run(&mut ctx, "");
match result {
CommandResult::Action(Action::SetTheme(name)) => {
// available[0] = GrokNight; next is available[1].
let expected = ThemeKind::available()[1].display_name();
assert_eq!(name, expected);
}
other => panic!("expected Action::SetTheme(...), got {other:?}"),
}
});
}
/// `/theme auto` dispatches `SetTheme("auto")`.
#[test]
fn run_auto_dispatches_set_theme_auto() {
with_test_env(|| {
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let bundle = crate::app::bundle::BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
let result = cmd.run(&mut ctx, "auto");
match result {
CommandResult::Action(Action::SetTheme(name)) => {
assert_eq!(name, "auto");
}
other => panic!("expected Action::SetTheme(\"auto\"), got {other:?}"),
}
});
}
/// Aliases normalise to canonical `display_name` before dispatch.
#[test]
fn run_alias_normalises_to_canonical() {
with_test_env(|| {
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let bundle = crate::app::bundle::BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
// "dark" is an alias for GrokNight.
let result = cmd.run(&mut ctx, "dark");
match result {
CommandResult::Action(Action::SetTheme(name)) => {
assert_eq!(name, "groknight", "alias must normalise to canonical");
}
other => panic!("expected Action::SetTheme(\"groknight\"), got {other:?}"),
}
});
}
// -- preview_arg ----------------------------------------------------------
#[test]
fn preview_auto_applies_resolved_theme() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
let cmd = ThemeCommand;
cmd.preview_arg("auto");
// Default auto config maps Light -> GrokDay.
assert_eq!(Theme::current_kind(), ThemeKind::GrokDay);
});
}
/// `preview_arg` applies the named theme directly.
#[test]
fn preview_explicit_theme_applies_directly() {
with_test_env(|| {
theme_cache::set(ThemeKind::GrokNight);
let cmd = ThemeCommand;
cmd.preview_arg("grokday");
assert_eq!(Theme::current_kind(), ThemeKind::GrokDay);
});
}
/// `preview_arg` with unknown theme is a no-op.
#[test]
fn preview_unknown_theme_is_no_op() {
with_test_env(|| {
theme_cache::set(ThemeKind::GrokNight);
let cmd = ThemeCommand;
cmd.preview_arg("nonexistent-theme");
assert_eq!(
Theme::current_kind(),
ThemeKind::GrokNight,
"unknown theme name must NOT change Theme::current_kind",
);
});
}
// -- cancel_preview -------------------------------------------------------
/// `cancel_preview` restores the previously-applied theme.
#[test]
fn cancel_preview_restores_previous_kind() {
with_test_env(|| {
theme_cache::set(ThemeKind::GrokNight);
let cmd = ThemeCommand;
// Simulate user navigating into a different theme during preview.
cmd.preview_arg("grokday");
assert_eq!(Theme::current_kind(), ThemeKind::GrokDay);
// Then Escape (or arg picker dismissal): restore.
cmd.cancel_preview("groknight");
assert_eq!(
Theme::current_kind(),
ThemeKind::GrokNight,
"cancel_preview must restore the previous canonical",
);
});
}
/// `cancel_preview` with unknown theme is a no-op.
#[test]
fn cancel_preview_unknown_theme_is_no_op() {
with_test_env(|| {
theme_cache::set(ThemeKind::GrokDay);
let cmd = ThemeCommand;
cmd.cancel_preview("nonexistent-theme");
assert_eq!(
Theme::current_kind(),
ThemeKind::GrokDay,
"unknown previous must NOT change Theme::current_kind",
);
});
}
// -- error handling -------------------------------------------------------
#[test]
fn run_unknown_lists_auto_in_available() {
with_test_env(|| {
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let bundle = crate::app::bundle::BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
let result = cmd.run(&mut ctx, "nonexistent");
if let CommandResult::Error(msg) = result {
assert!(msg.contains("auto"), "error should list auto: {msg}");
} else {
panic!("expected Error, got: {result:?}");
}
});
}
/// Truecolor-only themes are accepted; clamping happens downstream.
#[test]
fn run_truecolor_theme_dispatches_set_theme_action() {
with_test_env(|| {
let cmd = ThemeCommand;
let models = crate::acp::model_state::ModelState::default();
let bundle = crate::app::bundle::BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
};
let result = cmd.run(&mut ctx, "tokyonight");
match result {
CommandResult::Action(Action::SetTheme(name)) => {
assert_eq!(
name, "tokyonight",
"truecolor themes must be accepted; clamping happens \
downstream in `Theme::apply_kind`",
);
}
other => panic!("expected Action::SetTheme(\"tokyonight\"), got {other:?}"),
}
});
}
}

View file

@ -0,0 +1,43 @@
//! `/timeline` -- toggle the timeline sidebar (per-turn tick rail).
//!
//! Computes the new value itself and dispatches the typed
//! `Action::SetTimeline(bool)`, mirroring `/timestamps`.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct TimelineCommand;
impl SlashCommand for TimelineCommand {
fn name(&self) -> &str {
"timeline"
}
fn description(&self) -> &str {
"Toggle the timeline sidebar"
}
/// Minimal mode has no interactive scrollback pane for the rail.
fn available_in_minimal(&self) -> bool {
false
}
fn usage(&self) -> &str {
"/timeline"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let new = !crate::appearance::cache::load_show_timeline();
CommandResult::Action(Action::SetTimeline(new))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_available_in_minimal() {
assert!(!TimelineCommand.available_in_minimal());
}
}

View file

@ -0,0 +1,32 @@
//! `/timestamps` -- toggle timestamp display on messages.
//!
//! This command computes the new value itself and dispatches the typed
//! `Action::SetTimestamps(bool)`.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct TimestampsCommand;
impl SlashCommand for TimestampsCommand {
fn name(&self) -> &str {
"timestamps"
}
fn description(&self) -> &str {
"Toggle message timestamps on/off"
}
fn usage(&self) -> &str {
"/timestamps"
}
fn arg_placeholder(&self) -> Option<&str> {
Some("on/off")
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let new = !crate::appearance::cache::load_timestamps();
CommandResult::Action(Action::SetTimestamps(new))
}
}

View file

@ -0,0 +1,113 @@
//! `/toggle-mouse-reporting` — flip terminal mouse capture from anywhere.
//!
//! Opt-in companion to the `Ctrl+R` (scrollback-focused) shortcut. Disabling
//! capture hands mouse selection back to the terminal for native click-drag
//! copy/paste; re-enabling restores in-app mouse support. Unlike the keybinding,
//! the command runs from the prompt or scrollback without defocusing input.
//!
//! Gated on `[ui] mouse_reporting_toggle = true` (cached at startup in
//! [`crate::app::mouse_reporting_toggle_enabled`]): hidden from the dropdown and
//! inert (prints a hint) when the feature is off.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand};
/// Toggle terminal mouse reporting (mouse capture). Mirrors the `Ctrl+R`
/// scrollback shortcut via the same `Action::ToggleMouseCapture` path.
pub struct ToggleMouseReportingCommand;
impl SlashCommand for ToggleMouseReportingCommand {
fn name(&self) -> &str {
"toggle-mouse-reporting"
}
fn description(&self) -> &str {
"Toggle terminal mouse reporting (native click-drag copy/paste)"
}
fn usage(&self) -> &str {
"/toggle-mouse-reporting"
}
/// Only offered when the opt-in feature is enabled in config.
fn visible(&self, _ctx: &AppCtx) -> bool {
crate::app::mouse_reporting_toggle_enabled()
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if crate::app::mouse_reporting_toggle_enabled() {
CommandResult::Action(Action::ToggleMouseCapture)
} else {
CommandResult::Message(
"Mouse reporting toggle is off. Set `[ui] mouse_reporting_toggle = true` \
in ~/.grok/config.toml to enable it."
.to_string(),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use std::sync::atomic::Ordering;
fn set_enabled(on: bool) {
crate::app::MOUSE_REPORTING_TOGGLE_ENABLED.store(on, Ordering::Release);
}
fn exec_ctx<'a>(models: &'a ModelState, bundle: &'a BundleState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}
#[serial_test::serial(MOUSE_REPORTING_TOGGLE_ENABLED)]
#[test]
fn run_returns_toggle_action_when_enabled() {
set_enabled(true);
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = exec_ctx(&models, &bundle);
assert!(matches!(
ToggleMouseReportingCommand.run(&mut ctx, ""),
CommandResult::Action(Action::ToggleMouseCapture)
));
set_enabled(false);
}
#[serial_test::serial(MOUSE_REPORTING_TOGGLE_ENABLED)]
#[test]
fn run_returns_hint_message_when_disabled() {
set_enabled(false);
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = exec_ctx(&models, &bundle);
assert!(matches!(
ToggleMouseReportingCommand.run(&mut ctx, ""),
CommandResult::Message(_)
));
}
#[serial_test::serial(MOUSE_REPORTING_TOGGLE_ENABLED)]
#[test]
fn visible_tracks_config_flag() {
let models = ModelState::default();
let ctx = AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
set_enabled(true);
assert!(ToggleMouseReportingCommand.visible(&ctx));
set_enabled(false);
assert!(!ToggleMouseReportingCommand.visible(&ctx));
}
}

View file

@ -0,0 +1,99 @@
//! `/transcript` -- view the full conversation transcript in `$PAGER`.
//!
//! Renders the current session's transcript to a temp Markdown file and opens
//! it in the user's pager (default `less`), suspending the inline TUI until the
//! pager exits. Primarily for minimal mode, where there is no interactive
//! scrollback pane and older blocks have scrolled into native history — but it
//! works in every render mode.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// View the full conversation transcript in `$PAGER`.
pub struct TranscriptCommand;
impl SlashCommand for TranscriptCommand {
fn name(&self) -> &str {
"transcript"
}
fn aliases(&self) -> &[&str] {
&["log"]
}
fn description(&self) -> &str {
"View the full conversation transcript in your pager ($PAGER)"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/transcript"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session to view".to_string());
}
CommandResult::Action(Action::OpenTranscriptPager)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
static DEFAULT_BUNDLE_STATE: BundleState = BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
#[test]
fn no_session_errors() {
let models = ModelState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
pager_state: PagerLocalSnapshot::default(),
};
match TranscriptCommand.run(&mut ctx, "") {
CommandResult::Error(msg) => assert!(msg.contains("No active session")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn with_session_dispatches_open_transcript_pager() {
let models = ModelState::default();
let sid = agent_client_protocol::SessionId::from("s1".to_string());
let mut ctx = CommandExecCtx {
models: &models,
session_id: Some(&sid),
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
pager_state: PagerLocalSnapshot::default(),
};
assert!(matches!(
TranscriptCommand.run(&mut ctx, ""),
CommandResult::Action(Action::OpenTranscriptPager)
));
// Args are ignored — same dispatch.
assert!(matches!(
TranscriptCommand.run(&mut ctx, "anything"),
CommandResult::Action(Action::OpenTranscriptPager)
));
}
}

View file

@ -0,0 +1,70 @@
//! `/usage` -- show credit usage or open billing management page.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
/// Show coding credit usage or manage billing.
///
/// `/usage` -- show current credit usage
/// `/usage show` -- same as above
/// `/usage manage` -- open billing management page in browser
pub struct UsageCommand;
impl SlashCommand for UsageCommand {
fn name(&self) -> &str {
"usage"
}
/// `/cost` is the minimal-mode name for the same credit-usage summary:
/// it commits a usage/cost system block rather than opening a
/// pane, so it's an alias rather than a separate command.
fn aliases(&self) -> &[&str] {
&["cost"]
}
fn description(&self) -> &str {
"View credit usage or manage billing"
}
fn usage(&self) -> &str {
"/usage [show|manage]"
}
fn takes_args(&self) -> bool {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("show | manage")
}
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
Some(vec![
ArgItem {
display: "show".to_string(),
match_text: "show".to_string(),
insert_text: "show".to_string(),
description: "View credit usage".to_string(),
},
ArgItem {
display: "manage".to_string(),
match_text: "manage".to_string(),
insert_text: "manage".to_string(),
description: "Open billing management page".to_string(),
},
])
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let arg = args.trim();
match arg {
"" | "show" => CommandResult::Action(Action::ShowUsage),
"manage" => {
CommandResult::Action(Action::OpenUrl("https://grok.com/?_s=usage".to_string()))
}
_ => CommandResult::Error(format!(
"Unknown argument: {arg}. Use /usage show or /usage manage"
)),
}
}
}

View file

@ -0,0 +1,33 @@
//! `/view-plan` -- open the current saved plan preview.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Open the current session plan preview.
pub struct ViewPlanCommand;
impl SlashCommand for ViewPlanCommand {
fn name(&self) -> &str {
"view-plan"
}
fn aliases(&self) -> &[&str] {
&["show-plan", "plan-view"]
}
fn description(&self) -> &str {
"View the current plan"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/view-plan"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::ShowPlan)
}
}

View file

@ -0,0 +1,31 @@
//! `/vim-mode` -- toggle vim-style scrollback keybindings.
//!
//! When off (default), bare-letter and Shift+letter keys in the scrollback
//! (j/k, h/l, g/G, y/Y, o/O, r, x, e/E, L/H, plus the `i` insert alt)
//! are suppressed and instead jump focus to the prompt so the letter is
//! typed into the textarea. Arrow/Tab/Esc/Space/PgUp/PgDn and all
//! Ctrl+letter bindings stay active in both modes.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Toggle vim-style scrollback keybindings via `/vim-mode`.
pub struct VimModeCommand;
impl SlashCommand for VimModeCommand {
fn name(&self) -> &str {
"vim-mode"
}
fn description(&self) -> &str {
"Toggle vim-style scrollback keybindings (j/k, h/l, g/G, y/Y, …)"
}
fn usage(&self) -> &str {
"/vim-mode"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::ToggleVimMode)
}
}

View file

@ -0,0 +1,60 @@
//! `/voice` — toggle dictation: start recording now, stop with Esc or Enter
//! (Enter also sends). Not written to `config.toml`.
//!
//! The keyboard chord is **Ctrl+Space** or **F8** (both work; F8 is a fallback
//! where Ctrl+Space is taken — e.g. macOS input switching). Its behavior follows
//! `[ui].voice_capture_mode`: `toggle` (press starts, press again stops — like
//! `/voice`) or `hold`-to-talk (hold to record, release to stop), with `hold`
//! available only on terminals that report key releases (Kitty protocol) and
//! falling back to toggle elsewhere. The recording banner is the only feedback;
//! no toast.
//!
//! Dictation works on the agent screen (into the prompt) and on the dashboard
//! (into the dispatch / new-agent input).
//!
//! **Scope.** On for the rest of the process; re-open `grok` for a clean slate.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Session voice mode enable via `/voice`.
pub struct VoiceCommand;
impl SlashCommand for VoiceCommand {
fn name(&self) -> &str {
"voice"
}
fn description(&self) -> &str {
// Chord is Ctrl+Space or F8. On non-Kitty terminals hold-to-talk is
// impossible (no key releases), so it's always toggle — say so. On Kitty
// it's configurable (toggle or hold via `voice_capture_mode`), so leave
// the behavior unspecified.
if crate::app::kitty_flags_pushed() {
"Dictation (Ctrl+Space/F8; Esc/Enter to stop)"
} else {
"Toggle dictation (Ctrl+Space/F8; Esc/Enter to stop)"
}
}
fn usage(&self) -> &str {
"/voice"
}
/// Dictation targets a prompt box: the agent prompt in a live session, or
/// the dashboard's dispatch (new-agent) input. Session-scoped (no effect on
/// the welcome screen) but still offered on the dashboard.
fn session_scoped(&self) -> bool {
true
}
fn offered_when_session_less(&self) -> bool {
true
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
// Toggle, mirroring the voice key: starts dictation, or stops it
// if already recording (Esc/Enter also stop).
CommandResult::Action(Action::VoiceToggle)
}
}

View file

@ -0,0 +1,161 @@
//! Nucleo-based fuzzy matcher for slash command and argument suggestions.
//!
//! Thin
//! wrapper around nucleo's `MultiPattern` that provides ranked results
//! and highlight index extraction.
use nucleo::{
Config, Matcher, Utf32String,
pattern::{CaseMatching, MultiPattern, Normalization},
};
/// Fuzzy matcher backed by nucleo.
///
/// Maintains internal state (pattern + matcher) between calls for efficiency.
/// Not thread-safe -- intended for single-threaded use within `SlashController`.
#[derive(Debug)]
pub struct FuzzyMatcher {
pattern: MultiPattern,
matcher: Matcher,
}
impl Default for FuzzyMatcher {
fn default() -> Self {
Self::new()
}
}
impl FuzzyMatcher {
pub fn new() -> Self {
Self {
pattern: MultiPattern::new(1),
matcher: Matcher::new(Config::DEFAULT),
}
}
/// Rank items by fuzzy match score.
///
/// Returns `(index, score)` pairs sorted by descending score, then
/// ascending key text. At most `limit` results are returned.
///
/// When `query` is empty, returns the first `limit` items with score 0
/// (insertion order).
pub fn rank<T, F>(
&mut self,
items: &[T],
query: &str,
limit: usize,
mut key_fn: F,
) -> Vec<(usize, u32)>
where
F: FnMut(&T) -> &str,
{
if limit == 0 || items.is_empty() {
return Vec::new();
}
let trimmed = query.trim();
if trimmed.is_empty() {
let capped = items.len().min(limit);
return (0..capped).map(|idx| (idx, 0)).collect();
}
self.pattern
.reparse(0, trimmed, CaseMatching::Smart, Normalization::Smart, false);
let mut hits: Vec<(usize, u32, String)> = Vec::new();
for (idx, item) in items.iter().enumerate() {
let text = key_fn(item);
if text.is_empty() {
continue;
}
let matcher_text = Utf32String::from(text);
if let Some(score) = self
.pattern
.score(std::slice::from_ref(&matcher_text), &mut self.matcher)
{
hits.push((idx, score, text.to_owned()));
}
}
hits.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
if hits.len() > limit {
hits.truncate(limit);
}
hits.into_iter()
.map(|(idx, score, _)| (idx, score))
.collect()
}
/// Extract fuzzy match highlight indices for the most recent pattern.
///
/// Returns character positions in `text` that matched the pattern.
pub fn indices(&mut self, text: &str) -> Vec<u32> {
let mut indices = Vec::new();
if text.is_empty() {
return indices;
}
let s = Utf32String::from(text);
let pattern = self.pattern.column_pattern(0);
pattern.indices(s.slice(..), &mut self.matcher, &mut indices);
indices
}
}
#[cfg(test)]
mod tests {
use super::FuzzyMatcher;
#[test]
fn empty_query_yields_insertion_order() {
let mut matcher = FuzzyMatcher::new();
let items = ["alpha", "beta", "gamma"];
let hits = matcher.rank(&items, "", items.len(), |item| item);
assert_eq!(hits, vec![(0, 0), (1, 0), (2, 0)]);
}
#[test]
fn ranked_results_prioritize_matches() {
let mut matcher = FuzzyMatcher::new();
let items = ["model", "help", "history"];
let hits = matcher.rank(&items, "mod", items.len(), |item| item);
assert_eq!(hits.first().map(|&(idx, _)| items[idx]), Some("model"));
}
#[test]
fn limit_caps_results() {
let mut matcher = FuzzyMatcher::new();
let items = ["aaa", "aab", "aac", "aad", "aae"];
let hits = matcher.rank(&items, "a", 2, |item| item);
assert_eq!(hits.len(), 2);
}
#[test]
fn empty_items_returns_empty() {
let mut matcher = FuzzyMatcher::new();
let items: [&str; 0] = [];
let hits = matcher.rank(&items, "test", 10, |item| item);
assert!(hits.is_empty());
}
/// Single-letter `/p` ties many `p*` commands at the same nucleo score;
/// ordering is entirely secondary tiebreaks (display/builtin/MRU/etc.).
#[test]
fn query_p_ties_personas_and_pager_headless_at_same_score() {
let mut matcher = FuzzyMatcher::new();
let items = ["personas", "pager-headless", "plan", "plugins"];
let hits = matcher.rank(&items, "p", items.len(), |item| item);
let score_of = |name: &str| -> Option<u32> {
hits.iter()
.find(|&&(idx, _)| items[idx] == name)
.map(|&(_, s)| s)
};
let personas = score_of("personas").expect("personas matches p");
let pager = score_of("pager-headless").expect("pager-headless matches p");
assert_eq!(personas, pager, "expected equal fuzzy scores for /p case");
assert!(personas > 0);
// Matcher limit=1 secondary sort is ascending key text → pager-headless wins.
let top1 = matcher.rank(&items, "p", 1, |item| item);
assert_eq!(items[top1[0].0], "pager-headless");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,395 @@
//! Slash command MRU / recency (`$GROK_HOME/slash-mru.json`).
//!
//! Flat `command → last_used` map (canonical names). Tiebreaks use recency
//! decay (7-day half-life, 0.1 floor). Bounded to [`MAX_ENTRIES`].
//!
//! Ownership: each [`crate::slash::SlashController`] holds an
//! `Rc<RefCell<SlashMru>>` (single-threaded UI; no mutex). `AppView` owns one
//! store and injects it into every controller (agent prompts + dashboard
//! dispatch) so they stay in sync — no process-global singleton. Default and
//! test controllers get an isolated in-memory store (no disk I/O).
//!
//! Persistence: a `touch` only marks the store dirty (never blocks the UI on
//! disk). When a command is recorded, the controller hands an owned
//! [`MruSnapshot`] to [`persist_async`], which serializes writes through one
//! long-lived background thread (atomic temp-file + rename). The `Rc<RefCell>`
//! itself never crosses a thread boundary; only the `Send` snapshot does.
use std::collections::HashMap;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::mpsc::{self, Sender};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::util::grok_home;
const RECENCY_HALF_LIFE_SECS: f64 = 7.0 * 86_400.0;
const RECENCY_FLOOR: f64 = 0.1;
const MAX_ENTRIES: usize = 256;
/// On-disk format. `by_command` is canonical; legacy `by_prefix` is migrated once.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct MruFile {
#[serde(default)]
by_command: HashMap<String, u64>,
/// Legacy per-prefix schema (read-only migrate).
#[serde(default)]
by_prefix: HashMap<String, HashMap<String, u64>>,
}
#[derive(Debug)]
pub struct SlashMru {
by_command: HashMap<String, u64>,
loaded: bool,
dirty: bool,
/// When false (tests), never touch disk.
persist_enabled: bool,
}
impl Default for SlashMru {
fn default() -> Self {
Self {
by_command: HashMap::new(),
loaded: false,
dirty: false,
persist_enabled: true,
}
}
}
impl SlashMru {
pub fn new() -> Self {
Self::default()
}
/// Isolated store for unit tests (no disk I/O).
pub fn new_in_memory() -> Self {
Self {
loaded: true,
persist_enabled: false,
..Self::default()
}
}
fn store_path() -> PathBuf {
grok_home().join("slash-mru.json")
}
fn normalize_command(command_name: &str) -> Option<String> {
let name = command_name.trim().trim_start_matches('/');
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Recency-with-decay tiebreak score. Pure recency (one `last_used`
/// timestamp per command, no use-count) scaled by an exponential decay so a
/// long-stale entry can't win ties forever; the floor keeps any prior use
/// just above never-used.
fn recency_score(last_used: u64, now: u64) -> u64 {
if last_used == 0 {
return 0;
}
let age = now.saturating_sub(last_used) as f64;
let factor = (0.5_f64.powf(age / RECENCY_HALF_LIFE_SECS)).max(RECENCY_FLOOR);
((last_used as f64) * factor) as u64
}
fn ensure_loaded(&mut self) {
if self.loaded || !self.persist_enabled {
if !self.loaded {
self.loaded = true;
}
return;
}
let path = Self::store_path();
match fs::read(&path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {
self.loaded = true;
}
Err(e) => {
tracing::warn!(
error = %e,
"slash MRU: read failed; using empty store, persistence disabled for session"
);
// Mark loaded so we don't re-attempt the read on every
// `rank_score` (once per candidate per keystroke on the UI
// thread), and disable persistence so we never clobber a file
// we couldn't read.
self.loaded = true;
self.persist_enabled = false;
}
Ok(bytes) => match serde_json::from_slice::<MruFile>(&bytes) {
Ok(file) => {
self.by_command = file.by_command;
if self.by_command.is_empty() && !file.by_prefix.is_empty() {
// Collapse legacy per-prefix buckets: max timestamp per command.
for bucket in file.by_prefix.values() {
for (cmd, ts) in bucket {
let e = self.by_command.entry(cmd.clone()).or_insert(0);
*e = (*e).max(*ts);
}
}
self.dirty = true;
}
self.trim_to_cap();
self.loaded = true;
}
Err(e) => {
tracing::warn!(
error = %e,
"slash MRU: corrupt file ignored"
);
self.loaded = true;
}
},
}
}
fn trim_to_cap(&mut self) {
if self.by_command.len() <= MAX_ENTRIES {
return;
}
let mut entries: Vec<(String, u64)> = self.by_command.drain().collect();
entries.sort_by(|a, b| b.1.cmp(&a.1));
entries.truncate(MAX_ENTRIES);
self.by_command = entries.into_iter().collect();
}
/// Record use of a canonical command name (ignores typed prefix; flat model).
pub fn touch(&mut self, _typed_prefix: &str, command_name: &str) {
let Some(cmd) = Self::normalize_command(command_name) else {
return;
};
self.ensure_loaded();
let now = Self::now_secs();
self.by_command.insert(cmd, now);
self.trim_to_cap();
if self.persist_enabled {
self.dirty = true;
}
}
pub fn last_used(&mut self, _typed_prefix: &str, command_name: &str) -> u64 {
let Some(cmd) = Self::normalize_command(command_name) else {
return 0;
};
self.ensure_loaded();
self.by_command.get(&cmd).copied().unwrap_or(0)
}
pub fn rank_score(&mut self, _typed_prefix: &str, command_name: &str) -> u64 {
let ts = self.last_used("", command_name);
Self::recency_score(ts, Self::now_secs())
}
/// Take an owned, `Send` snapshot to persist when dirty; clears the dirty
/// flag. Returns `None` when persistence is disabled (tests) or nothing
/// changed. The snapshot is written off the UI thread by [`persist_async`].
pub fn take_persist_snapshot(&mut self) -> Option<MruSnapshot> {
if !self.persist_enabled || !self.dirty {
return None;
}
let file = MruFile {
by_command: self.by_command.clone(),
by_prefix: HashMap::new(),
};
let bytes = serde_json::to_vec(&file).ok()?;
self.dirty = false;
Some(MruSnapshot {
path: Self::store_path(),
bytes,
})
}
/// Re-flag unpersisted changes after a failed write so the next
/// [`Self::take_persist_snapshot`] retries. No-op when persistence is off.
pub fn mark_dirty(&mut self) {
if self.persist_enabled {
self.dirty = true;
}
}
#[cfg(test)]
pub fn seed_for_test(&mut self, _prefix: &str, command_name: &str, last_used: u64) {
self.loaded = true;
self.persist_enabled = false;
if let Some(cmd) = Self::normalize_command(command_name) {
self.by_command.insert(cmd, last_used);
}
}
}
/// An owned, `Send` snapshot of the MRU ready to write to disk. Produced on
/// the UI thread by [`SlashMru::take_persist_snapshot`]; written off-thread.
#[derive(Debug)]
pub struct MruSnapshot {
path: PathBuf,
bytes: Vec<u8>,
}
impl MruSnapshot {
/// Atomic write (temp file + `fsync` + rename). Returns `true` on success.
/// Safe on a worker thread.
fn write(&self) -> bool {
if let Some(parent) = self.path.parent() {
let _ = fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
let write_ok = (|| -> io::Result<()> {
let mut f = fs::File::create(&tmp)?;
f.write_all(&self.bytes)?;
f.sync_all()?;
fs::rename(&tmp, &self.path)?;
Ok(())
})();
match write_ok {
Ok(()) => true,
Err(e) => {
tracing::debug!(error = %e, "slash MRU: persist failed");
let _ = fs::remove_file(&tmp);
false
}
}
}
}
/// Persist a snapshot off the UI thread. Writes are serialized through a single
/// long-lived background thread (created on first use), so concurrent accepts
/// can never reorder or tear the on-disk file. The send is non-blocking; the
/// `Rc<RefCell<SlashMru>>` never leaves the UI thread (only the `Send` snapshot
/// does).
///
/// Returns `true` if the snapshot was handed to the writer thread or written
/// synchronously; `false` only when no write could be attempted (so the caller
/// can keep the store dirty and retry on the next record). If the writer thread
/// can't be spawned, or its channel has hung up, this falls back to a
/// best-effort synchronous write rather than silently dropping the update.
///
/// The off-thread write is itself best-effort: a transient disk failure is
/// self-healing because each snapshot is the full command map (not a delta), so
/// the next `record_command_use` re-persists everything.
///
/// The writer channel is the only process-global piece — write-only I/O
/// plumbing, not shared ranking state — so it carries none of the
/// singleton/test-seam baggage that an injected store avoids.
pub fn persist_async(snapshot: MruSnapshot) -> bool {
static WRITER: OnceLock<Option<Sender<MruSnapshot>>> = OnceLock::new();
let tx = WRITER.get_or_init(|| {
let (tx, rx) = mpsc::channel::<MruSnapshot>();
match std::thread::Builder::new()
.name("slash-mru-writer".to_string())
.spawn(move || {
while let Ok(snapshot) = rx.recv() {
snapshot.write();
}
}) {
Ok(_) => Some(tx),
Err(e) => {
tracing::debug!(error = %e, "slash MRU: writer thread spawn failed; writing synchronously");
None
}
}
});
match tx {
Some(tx) => match tx.send(snapshot) {
Ok(()) => true,
// Writer thread gone: best-effort synchronous write of the snapshot
// returned in the send error rather than dropping it.
Err(e) => e.0.write(),
},
// Writer thread never started: best-effort synchronous write.
None => snapshot.write(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn touch_is_flat_by_command() {
let mut mru = SlashMru::new_in_memory();
mru.touch("p", "pager-headless");
mru.touch("q", "quit");
assert!(mru.last_used("anything", "pager-headless") > 0);
assert!(mru.last_used("x", "quit") > 0);
// Flat: prefix does not scope records.
assert_eq!(mru.last_used("p", "quit"), mru.last_used("q", "quit"));
}
#[test]
fn strips_leading_slash_on_command() {
let mut mru = SlashMru::new_in_memory();
mru.touch("m", "/model");
assert!(mru.last_used("", "model") > 0);
assert_eq!(mru.last_used("", "/model"), mru.last_used("", "model"));
}
#[test]
fn recency_decays_stale_entries() {
let now = 1_700_000_000_u64;
let recent = SlashMru::recency_score(now - 60, now);
let week_old = SlashMru::recency_score(now - 7 * 86_400, now);
let month_old = SlashMru::recency_score(now - 30 * 86_400, now);
assert!(recent > week_old);
assert!(week_old > month_old);
assert!(month_old > 0);
assert_eq!(SlashMru::recency_score(0, now), 0);
}
#[test]
fn in_memory_store_never_dirties_for_disk() {
let mut mru = SlashMru::new_in_memory();
mru.touch("p", "plan");
assert!(!mru.dirty);
// In-memory stores never produce a persist snapshot (no disk I/O).
assert!(mru.take_persist_snapshot().is_none());
}
#[test]
fn dirty_store_yields_one_snapshot_then_clears() {
let mut mru = SlashMru::new(); // persist-enabled
mru.loaded = true; // avoid disk read in test
mru.touch("p", "plan");
assert!(mru.dirty);
assert!(mru.take_persist_snapshot().is_some());
// Dirty flag cleared; no redundant second write.
assert!(!mru.dirty);
assert!(mru.take_persist_snapshot().is_none());
}
#[test]
fn mark_dirty_requeues_after_failed_write() {
// A snapshot was taken (dirty cleared) but the write could not be
// handed off; mark_dirty re-queues it so the next call retries.
let mut mru = SlashMru::new();
mru.loaded = true;
mru.touch("p", "plan");
assert!(mru.take_persist_snapshot().is_some());
assert!(mru.take_persist_snapshot().is_none()); // nothing to retry yet
mru.mark_dirty();
assert!(mru.take_persist_snapshot().is_some()); // retried
}
#[test]
fn mark_dirty_noop_when_persistence_disabled() {
let mut mru = SlashMru::new_in_memory();
mru.mark_dirty();
assert!(!mru.dirty);
assert!(mru.take_persist_snapshot().is_none());
}
}

File diff suppressed because it is too large Load diff