Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
512
crates/codegen/xai-grok-pager/src/slash/acp_command.rs
Normal file
512
crates/codegen/xai-grok-pager/src/slash/acp_command.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
306
crates/codegen/xai-grok-pager/src/slash/command.rs
Normal file
306
crates/codegen/xai-grok-pager/src/slash/command.rs
Normal 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;
|
||||
}
|
||||
|
|
@ -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))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
114
crates/codegen/xai-grok-pager/src/slash/commands/auto.rs
Normal file
114
crates/codegen/xai-grok-pager/src/slash/commands/auto.rs
Normal 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))
|
||||
));
|
||||
}
|
||||
}
|
||||
43
crates/codegen/xai-grok-pager/src/slash/commands/btw.rs
Normal file
43
crates/codegen/xai-grok-pager/src/slash/commands/btw.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
122
crates/codegen/xai-grok-pager/src/slash/commands/cd.rs
Normal file
122
crates/codegen/xai-grok-pager/src/slash/commands/cd.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
51
crates/codegen/xai-grok-pager/src/slash/commands/compact.rs
Normal file
51
crates/codegen/xai-grok-pager/src/slash/commands/compact.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
33
crates/codegen/xai-grok-pager/src/slash/commands/context.rs
Normal file
33
crates/codegen/xai-grok-pager/src/slash/commands/context.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
159
crates/codegen/xai-grok-pager/src/slash/commands/copy.rs
Normal file
159
crates/codegen/xai-grok-pager/src/slash/commands/copy.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
147
crates/codegen/xai-grok-pager/src/slash/commands/dashboard.rs
Normal file
147
crates/codegen/xai-grok-pager/src/slash/commands/dashboard.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
197
crates/codegen/xai-grok-pager/src/slash/commands/debug.rs
Normal file
197
crates/codegen/xai-grok-pager/src/slash/commands/debug.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
221
crates/codegen/xai-grok-pager/src/slash/commands/docs.rs
Normal file
221
crates/codegen/xai-grok-pager/src/slash/commands/docs.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
366
crates/codegen/xai-grok-pager/src/slash/commands/effort.rs
Normal file
366
crates/codegen/xai-grok-pager/src/slash/commands/effort.rs
Normal 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 "));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
29
crates/codegen/xai-grok-pager/src/slash/commands/exit.rs
Normal file
29
crates/codegen/xai-grok-pager/src/slash/commands/exit.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
113
crates/codegen/xai-grok-pager/src/slash/commands/expand.rs
Normal file
113
crates/codegen/xai-grok-pager/src/slash/commands/expand.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
242
crates/codegen/xai-grok-pager/src/slash/commands/export.rs
Normal file
242
crates/codegen/xai-grok-pager/src/slash/commands/export.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
38
crates/codegen/xai-grok-pager/src/slash/commands/feedback.rs
Normal file
38
crates/codegen/xai-grok-pager/src/slash/commands/feedback.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
132
crates/codegen/xai-grok-pager/src/slash/commands/find.rs
Normal file
132
crates/codegen/xai-grok-pager/src/slash/commands/find.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
337
crates/codegen/xai-grok-pager/src/slash/commands/fork.rs
Normal file
337
crates/codegen/xai-grok-pager/src/slash/commands/fork.rs
Normal 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]"));
|
||||
}
|
||||
}
|
||||
46
crates/codegen/xai-grok-pager/src/slash/commands/gboom.rs
Normal file
46
crates/codegen/xai-grok-pager/src/slash/commands/gboom.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
64
crates/codegen/xai-grok-pager/src/slash/commands/help.rs
Normal file
64
crates/codegen/xai-grok-pager/src/slash/commands/help.rs
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
78
crates/codegen/xai-grok-pager/src/slash/commands/history.rs
Normal file
78
crates/codegen/xai-grok-pager/src/slash/commands/history.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
29
crates/codegen/xai-grok-pager/src/slash/commands/home.rs
Normal file
29
crates/codegen/xai-grok-pager/src/slash/commands/home.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
108
crates/codegen/xai-grok-pager/src/slash/commands/imagine.rs
Normal file
108
crates/codegen/xai-grok-pager/src/slash/commands/imagine.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
74
crates/codegen/xai-grok-pager/src/slash/commands/jump.rs
Normal file
74
crates/codegen/xai-grok-pager/src/slash/commands/jump.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
24
crates/codegen/xai-grok-pager/src/slash/commands/login.rs
Normal file
24
crates/codegen/xai-grok-pager/src/slash/commands/login.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
24
crates/codegen/xai-grok-pager/src/slash/commands/logout.rs
Normal file
24
crates/codegen/xai-grok-pager/src/slash/commands/logout.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
373
crates/codegen/xai-grok-pager/src/slash/commands/loop_cmd.rs
Normal file
373
crates/codegen/xai-grok-pager/src/slash/commands/loop_cmd.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
25
crates/codegen/xai-grok-pager/src/slash/commands/mcps.rs
Normal file
25
crates/codegen/xai-grok-pager/src/slash/commands/mcps.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
683
crates/codegen/xai-grok-pager/src/slash/commands/mod.rs
Normal file
683
crates/codegen/xai-grok-pager/src/slash/commands/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
484
crates/codegen/xai-grok-pager/src/slash/commands/model.rs
Normal file
484
crates/codegen/xai-grok-pager/src/slash/commands/model.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
133
crates/codegen/xai-grok-pager/src/slash/commands/multiline.rs
Normal file
133
crates/codegen/xai-grok-pager/src/slash/commands/multiline.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
29
crates/codegen/xai-grok-pager/src/slash/commands/new.rs
Normal file
29
crates/codegen/xai-grok-pager/src/slash/commands/new.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
30
crates/codegen/xai-grok-pager/src/slash/commands/personas.rs
Normal file
30
crates/codegen/xai-grok-pager/src/slash/commands/personas.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
193
crates/codegen/xai-grok-pager/src/slash/commands/plan.rs
Normal file
193
crates/codegen/xai-grok-pager/src/slash/commands/plan.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
106
crates/codegen/xai-grok-pager/src/slash/commands/plugin.rs
Normal file
106
crates/codegen/xai-grok-pager/src/slash/commands/plugin.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
212
crates/codegen/xai-grok-pager/src/slash/commands/privacy.rs
Normal file
212
crates/codegen/xai-grok-pager/src/slash/commands/privacy.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
90
crates/codegen/xai-grok-pager/src/slash/commands/queue.rs
Normal file
90
crates/codegen/xai-grok-pager/src/slash/commands/queue.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
33
crates/codegen/xai-grok-pager/src/slash/commands/recap.rs
Normal file
33
crates/codegen/xai-grok-pager/src/slash/commands/recap.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
38
crates/codegen/xai-grok-pager/src/slash/commands/remember.rs
Normal file
38
crates/codegen/xai-grok-pager/src/slash/commands/remember.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
54
crates/codegen/xai-grok-pager/src/slash/commands/rename.rs
Normal file
54
crates/codegen/xai-grok-pager/src/slash/commands/rename.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
24
crates/codegen/xai-grok-pager/src/slash/commands/resume.rs
Normal file
24
crates/codegen/xai-grok-pager/src/slash/commands/resume.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
26
crates/codegen/xai-grok-pager/src/slash/commands/rewind.rs
Normal file
26
crates/codegen/xai-grok-pager/src/slash/commands/rewind.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
34
crates/codegen/xai-grok-pager/src/slash/commands/share.rs
Normal file
34
crates/codegen/xai-grok-pager/src/slash/commands/share.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
91
crates/codegen/xai-grok-pager/src/slash/commands/tasks.rs
Normal file
91
crates/codegen/xai-grok-pager/src/slash/commands/tasks.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
551
crates/codegen/xai-grok-pager/src/slash/commands/theme.rs
Normal file
551
crates/codegen/xai-grok-pager/src/slash/commands/theme.rs
Normal 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:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
43
crates/codegen/xai-grok-pager/src/slash/commands/timeline.rs
Normal file
43
crates/codegen/xai-grok-pager/src/slash/commands/timeline.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
70
crates/codegen/xai-grok-pager/src/slash/commands/usage.rs
Normal file
70
crates/codegen/xai-grok-pager/src/slash/commands/usage.rs
Normal 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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
31
crates/codegen/xai-grok-pager/src/slash/commands/vim_mode.rs
Normal file
31
crates/codegen/xai-grok-pager/src/slash/commands/vim_mode.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
60
crates/codegen/xai-grok-pager/src/slash/commands/voice.rs
Normal file
60
crates/codegen/xai-grok-pager/src/slash/commands/voice.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
161
crates/codegen/xai-grok-pager/src/slash/matcher.rs
Normal file
161
crates/codegen/xai-grok-pager/src/slash/matcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
2685
crates/codegen/xai-grok-pager/src/slash/mod.rs
Normal file
2685
crates/codegen/xai-grok-pager/src/slash/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
395
crates/codegen/xai-grok-pager/src/slash/mru.rs
Normal file
395
crates/codegen/xai-grok-pager/src/slash/mru.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1178
crates/codegen/xai-grok-pager/src/slash/registry.rs
Normal file
1178
crates/codegen/xai-grok-pager/src/slash/registry.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue