Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -129,8 +129,9 @@ impl From<&acp::AvailableCommand> for AcpSlashCommand {
.and_then(|v| serde_json::from_value(v.clone()).ok());
if path.is_some() && scope.is_some() {
(path, scope, false)
} else if m.get("scope").is_some() && scope.is_none() {
(None, None, 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)
@ -175,6 +176,36 @@ mod tests {
assert!(!acp_cmd.meta_malformed);
}
#[test]
fn unknown_scope_passes_through_instead_of_erroring() {
let cmd = make_cmd(
"pr-cleanup",
Some(serde_json::json!({
"scope": "workflow",
"path": ".grok/workflows/pr-cleanup.rhai",
})),
);
let acp_cmd = AcpSlashCommand::from(&cmd);
assert!(!acp_cmd.meta_malformed);
assert!(!acp_cmd.is_skill());
let models = crate::acp::model_state::ModelState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &crate::app::bundle::BundleState::default(),
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
match acp_cmd.run(&mut ctx, "fix the branch") {
CommandResult::PassThrough(text) => {
assert_eq!(text, "/pr-cleanup fix the branch");
}
other => panic!("expected PassThrough, got {other:?}"),
}
}
#[test]
fn valid_skill_meta_populates_fields() {
let meta = serde_json::json!({
@ -192,7 +223,7 @@ mod tests {
}
#[test]
fn invalid_scope_value_is_malformed() {
fn unknown_scope_value_is_foreign_kind_not_malformed() {
let meta = serde_json::json!({
"scope": "invalid_scope",
"path": "/path/to/SKILL.md"
@ -201,7 +232,7 @@ mod tests {
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);
assert!(!acp_cmd.meta_malformed);
}
#[test]
@ -290,6 +321,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,

View file

@ -99,6 +99,9 @@ pub struct AppCtx<'a> {
pub cwd: &'a std::path::Path,
/// Session announcements (critical or promo) exist (gates `/announcements` visibility).
pub has_session_announcements: bool,
/// Consumer billing surface (`AppView::usage_visible`). Gates `/usage` subcommands.
pub billing_surface_visible: bool,
pub workflows_available: bool,
/// Effective render mode of this process (gates `/minimal` and
/// `/fullscreen` visibility). Same source of truth as
/// [`CommandExecCtx::screen_mode`], carried by the owning
@ -115,6 +118,8 @@ pub struct CommandExecCtx<'a> {
pub session_id: Option<&'a acp::SessionId>,
pub bundle_state: &'a BundleState,
pub(crate) screen_mode: crate::app::ScreenMode,
/// Consumer billing surface (`AppView::usage_visible`). Gates `/usage` subcommands.
pub billing_surface_visible: bool,
/// 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
@ -149,6 +154,16 @@ pub trait SlashCommand: Send + Sync {
false
}
/// Runtime args contract (e.g. subcommands only for some auth modes).
/// Defaults to [`Self::takes_args`]. Dropdown/completion paths only:
/// insert text (trailing space), the args-phase snapshot, and argument
/// suggestions. Enter-completeness ([`crate::slash::is_command_complete`])
/// keys off the static [`Self::takes_args`] / [`Self::args_required`] pair.
#[allow(unused_variables)]
fn takes_args_now(&self, ctx: &AppCtx) -> bool {
self.takes_args()
}
/// Whether arguments are required for execution.
///
/// Only meaningful when `takes_args()` is true. The two-bit model:

View file

@ -49,6 +49,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot {
multiline_mode: false,
yolo_mode,

View file

@ -78,6 +78,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
AnnouncementsCommand.run(&mut ctx, args)
@ -120,6 +121,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: true,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = AnnouncementsCommand
@ -139,12 +142,16 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
}));
assert!(cmd.visible(&AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: true,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
}));
}

View file

@ -58,6 +58,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot {
yolo_mode,
auto_mode,

View file

@ -67,6 +67,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,

View file

@ -99,6 +99,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}

View file

@ -83,6 +83,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -109,6 +110,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode,
};
assert!(cmd.visible(&ctx(crate::app::ScreenMode::Fullscreen)));

View file

@ -104,6 +104,8 @@ mod tests {
models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
}
}

View file

@ -124,6 +124,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -211,6 +212,8 @@ mod tests {
models: &models,
cwd,
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = DocsCommand.suggest_args(&ctx, "").expect("suggestions");

View file

@ -0,0 +1,50 @@
//! `/doctor` — diagnose terminal, color/theme, clipboard, and voice input.
//!
//! Runs the shared TUI probe and diagnostics path, including live runtime
//! evidence that the standalone command cannot observe.
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct DoctorCommand;
impl SlashCommand for DoctorCommand {
fn name(&self) -> &str {
"doctor"
}
fn aliases(&self) -> &[&str] {
&["terminal-setup", "terminal-check", "terminal-info"]
}
fn description(&self) -> &str {
"Check terminal, color, clipboard, and voice input"
}
fn usage(&self) -> &str {
"/doctor"
}
fn session_scoped(&self) -> bool {
true
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let terminal = crate::terminal::terminal_context();
let query = crate::diagnostics::probes::LiveTmuxProbe;
let snapshot = crate::diagnostics::probes::collect_doctor_tui(
terminal,
crate::diagnostics::probes::TuiProbeEvidence {
fullscreen_active: ctx.screen_mode.is_fullscreen(),
kitty_flags_pushed: crate::app::kitty_flags_pushed(),
xtversion: crate::terminal::xtversion::detected(),
},
&query,
);
let mut report = crate::diagnostics::view(snapshot.into());
// Passive enumeration cannot detect a denied macOS grant; capture reports that separately.
if crate::app::voice_mode_enabled() {
crate::diagnostics::apply_voice_probe(&mut report, true);
}
CommandResult::Message(crate::diagnostics::format_doctor(&report))
}
}

View file

@ -0,0 +1,129 @@
//! `/edit-prompt` -- edit the minimal-mode composer in an external editor.
use crate::app::actions::Action;
use crate::slash::command::{AppCtx, CommandExecCtx, CommandResult, SlashCommand};
/// Minimal-only fallback for terminals that reserve `Ctrl+G`.
pub struct EditPromptCommand;
impl SlashCommand for EditPromptCommand {
fn name(&self) -> &str {
"edit-prompt"
}
fn description(&self) -> &str {
"Open an external editor for an empty prompt; use the command palette to preserve a draft"
}
fn usage(&self) -> &str {
"/edit-prompt"
}
fn session_scoped(&self) -> bool {
true
}
fn visible(&self, ctx: &AppCtx) -> bool {
ctx.screen_mode.is_minimal()
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if !ctx.screen_mode.is_minimal() {
return CommandResult::Error(
"/edit-prompt is only available in minimal mode".to_owned(),
);
}
if ctx.session_id.is_none() {
return CommandResult::Error("No active session".to_owned());
}
CommandResult::Action(Action::EditPromptExternal)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
use crate::settings::PagerLocalSnapshot;
fn app_ctx<'a>(models: &'a ModelState, mode: crate::app::ScreenMode) -> AppCtx<'a> {
AppCtx {
models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
screen_mode: mode,
workflows_available: true,
}
}
fn exec_ctx<'a>(
models: &'a ModelState,
bundle: &'a BundleState,
session_id: Option<&'a agent_client_protocol::SessionId>,
mode: crate::app::ScreenMode,
) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id,
bundle_state: bundle,
screen_mode: mode,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
}
}
#[test]
fn visible_and_executable_only_in_minimal() {
let command = EditPromptCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let session_id = agent_client_protocol::SessionId::from("session".to_owned());
assert!(command.visible(&app_ctx(&models, crate::app::ScreenMode::Minimal)));
assert!(!command.visible(&app_ctx(&models, crate::app::ScreenMode::Fullscreen)));
assert!(matches!(
command.run(
&mut exec_ctx(
&models,
&bundle,
Some(&session_id),
crate::app::ScreenMode::Minimal,
),
"",
),
CommandResult::Action(Action::EditPromptExternal)
));
assert!(matches!(
command.run(
&mut exec_ctx(
&models,
&bundle,
Some(&session_id),
crate::app::ScreenMode::Fullscreen,
),
"",
),
CommandResult::Error(message) if message.contains("only available in minimal mode")
));
}
#[test]
fn requires_session() {
let models = ModelState::default();
let bundle = BundleState::default();
assert!(matches!(
EditPromptCommand.run(
&mut exec_ctx(
&models,
&bundle,
None,
crate::app::ScreenMode::Minimal,
),
"",
),
CommandResult::Error(message) if message.contains("No active session")
));
}
}

View file

@ -135,6 +135,7 @@ mod tests {
session_id: None,
bundle_state: &EMPTY_BUNDLE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -321,6 +322,8 @@ mod tests {
models: &empty,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(cmd.suggest_args(&ctx, "").is_none());
@ -333,6 +336,8 @@ mod tests {
models: &plain,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(cmd.suggest_args(&ctx, "").is_none());
@ -351,6 +356,8 @@ mod tests {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").unwrap();

View file

@ -20,7 +20,8 @@ pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str {
ReasoningEffort::Low => "Faster, lighter reasoning",
ReasoningEffort::Medium => "Balanced reasoning",
ReasoningEffort::High => "Heavy reasoning",
ReasoningEffort::Xhigh => "Maximum reasoning",
ReasoningEffort::Xhigh => "Extended reasoning",
ReasoningEffort::Max => "Maximum reasoning",
}
}

View file

@ -74,6 +74,7 @@ mod tests {
models,
session_id,
bundle_state: &DEFAULT_BUNDLE_STATE,
billing_surface_visible: true,
screen_mode,
pager_state: PagerLocalSnapshot::default(),
}

View file

@ -184,6 +184,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
}
}
@ -208,6 +209,7 @@ mod tests {
session_id: Some(&sid),
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
let cmd = ExportCommand;
@ -228,6 +230,7 @@ mod tests {
session_id: Some(&sid),
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
let cmd = ExportCommand;

View file

@ -72,6 +72,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}

View file

@ -264,6 +264,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,

View file

@ -54,6 +54,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
assert!(matches!(

View file

@ -46,6 +46,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
}
}

View file

@ -57,6 +57,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Fullscreen,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
let result = JumpCommand.run(&mut ctx, "");

View file

@ -261,6 +261,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
LoopCommand.run(&mut ctx, args)

View file

@ -16,6 +16,8 @@ pub mod copy;
pub mod dashboard;
pub mod debug;
pub mod docs;
pub mod doctor;
pub mod edit_prompt;
pub mod effort;
pub mod effort_levels;
pub mod exit;
@ -56,7 +58,6 @@ 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;
@ -66,6 +67,7 @@ pub mod usage;
pub mod view_plan;
pub mod vim_mode;
pub mod voice;
pub mod workflows;
use super::command::SlashCommand;
use std::sync::Arc;
/// All pager-local builtin commands, in display order.
@ -79,6 +81,7 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(docs::DocsCommand),
Arc::new(home::HomeCommand),
Arc::new(new::NewCommand),
Arc::new(fork::ForkCommand),
Arc::new(compact::CompactCommand),
Arc::new(copy::CopyCommand),
@ -86,6 +89,7 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(history::HistoryCommand),
Arc::new(export::ExportCommand),
Arc::new(transcript::TranscriptCommand),
Arc::new(edit_prompt::EditPromptCommand),
Arc::new(expand::ExpandCommand),
Arc::new(context::ContextCommand),
Arc::new(screen_mode_switch::ScreenModeSwitchCommand::minimal()),
@ -114,9 +118,11 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(view_plan::ViewPlanCommand),
Arc::new(resume::ResumeCommand),
Arc::new(mcps::McpsCommand),
Arc::new(workflows::WorkflowsCommand),
Arc::new(btw::BtwCommand),
Arc::new(recap::RecapCommand),
Arc::new(terminal_setup::TerminalSetupCommand),
Arc::new(doctor::DoctorCommand),
Arc::new(voice::VoiceCommand),
Arc::new(loop_cmd::LoopCommand),
Arc::new(imagine::ImagineCommand),
@ -183,6 +189,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -230,6 +237,112 @@ mod tests {
assert!(reg.get("loop").is_some());
}
#[test]
fn shell_collision_contract_covers_every_pager_command_and_alias() {
const SHELL_RESERVED: &[&str] = &[
"agents",
"agents-dashboard",
"always-approve",
"announcements",
"auto",
"btw",
"cd",
"changelog",
"chat",
"clear",
"cloud",
"compact",
"compact-mode",
"config",
"config-agents",
"context",
"copy",
"cost",
"dashboard",
"debug",
"docs",
"doctor",
"edit-prompt",
"effort",
"exit",
"expand",
"export",
"feedback",
"find",
"fork",
"full",
"fullscreen",
"gboom",
"guides",
"help",
"history",
"home",
"hooks",
"howto",
"imagine",
"imagine-video",
"import-claude",
"jump",
"login",
"logout",
"log",
"loop",
"m",
"marketplace",
"mcps",
"minimal",
"ml",
"model",
"multiline",
"new",
"personas",
"plan",
"plan-view",
"plugins",
"preferences",
"prefs",
"privacy",
"queue",
"quit",
"recap",
"release-notes",
"remember",
"rename",
"resume",
"rewind",
"scroll-debug",
"session-info",
"sessions",
"settings",
"share",
"show-plan",
"skills",
"summarize",
"tasks",
"terminal-check",
"terminal-info",
"terminal-setup",
"theme",
"timeline",
"timestamps",
"title",
"toggle-mouse-reporting",
"transcript",
"t",
"usage",
"view-plan",
"vim-mode",
"voice",
"welcome",
"workflows",
"yolo",
];
for command in builtin_commands() {
for key in std::iter::once(command.name()).chain(command.aliases().iter().copied()) {
assert!(SHELL_RESERVED.contains(&key), "unreserved pager key {key}");
}
}
}
#[test]
fn builtin_registry_lookup_by_alias() {
let reg = CommandRegistry::new(builtin_commands());
assert!(reg.get("exit").is_some());
@ -240,11 +353,17 @@ mod tests {
assert!(reg.get("plan-view").is_some());
}
#[test]
fn alias_resolves_to_same_command() {
fn aliases_resolve_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());
let doctor = reg.get("doctor").unwrap();
assert_eq!(doctor.usage(), "/doctor");
for alias in ["terminal-setup", "terminal-check", "terminal-info"] {
assert_eq!(reg.get(alias).unwrap().name(), doctor.name());
assert_eq!(reg.get(alias).unwrap().usage(), doctor.usage());
}
}
#[test]
fn exit_returns_quit_action() {
@ -393,6 +512,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let cmd = model::ModelCommand;
@ -416,6 +537,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let cmd = model::ModelCommand;
@ -456,99 +579,81 @@ mod tests {
CommandResult::Action(Action::EnterRememberMode)
));
}
fn run_usage(args: &str) -> CommandResult {
fn run_usage(args: &str, billing: bool) -> CommandResult {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
ctx.billing_surface_visible = billing;
usage::UsageCommand.run(&mut ctx, args)
}
#[test]
fn usage_no_args_returns_show_usage() {
fn usage_consumer_show_and_manage() {
assert!(matches!(
run_usage(""),
run_usage("", true),
CommandResult::Action(Action::ShowUsage)
));
}
#[test]
fn usage_show_returns_show_usage() {
assert!(matches!(
run_usage("show"),
run_usage("show", true),
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(" "),
run_usage(" manage ", true),
CommandResult::Action(Action::ManageBilling)
));
assert!(matches!(run_usage("delete", true), CommandResult::Error(_)));
}
#[test]
fn usage_non_consumer_is_bare_only() {
assert!(matches!(
run_usage("", false),
CommandResult::Action(Action::ShowUsage)
));
}
#[test]
fn usage_show_with_leading_whitespace() {
assert!(matches!(
run_usage(" show "),
CommandResult::Action(Action::ShowUsage)
run_usage("manage", false),
CommandResult::Error(_)
));
assert!(matches!(run_usage("show", false), CommandResult::Error(_)));
}
#[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() {
fn usage_takes_args_only_for_consumer() {
let models = ModelState::default();
let ctx = crate::slash::command::AppCtx {
let mut ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
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");
let cmd = usage::UsageCommand;
assert!(cmd.takes_args_now(&ctx));
ctx.billing_surface_visible = false;
assert!(!cmd.takes_args_now(&ctx));
}
#[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());
fn usage_suggest_args_consumer_only() {
let models = ModelState::default();
let mut ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = usage::UsageCommand.suggest_args(&ctx, "").unwrap();
assert_eq!(
items.iter().map(|i| i.display.as_str()).collect::<Vec<_>>(),
["show", "manage"]
);
ctx.billing_surface_visible = false;
assert!(usage::UsageCommand.suggest_args(&ctx, "").is_none());
}
#[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"
CommandRegistry::new(builtin_commands())
.get("usage")
.is_some()
);
}
#[test]
@ -578,8 +683,7 @@ mod tests {
#[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");
assert_eq!(reg.get("cost").expect("/cost").name(), "usage");
}
#[test]
fn debug_is_registered_and_executable() {
@ -598,6 +702,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(

View file

@ -241,6 +241,7 @@ mod tests {
session_id: None,
bundle_state: &EMPTY_BUNDLE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -276,6 +277,8 @@ mod tests {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").unwrap();
@ -306,6 +309,8 @@ mod tests {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
// Args query has a trailing space -> effort phase. Items come out
@ -335,6 +340,8 @@ mod tests {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
// Still in effort phase; matcher upstream narrows to high / xhigh.
@ -353,6 +360,8 @@ mod tests {
models: &state,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
// No trailing space, user is still typing the model name.

View file

@ -64,6 +64,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot {
multiline_mode,
yolo_mode: false,

View file

@ -69,6 +69,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot {
plan_mode_active: false,
..PagerLocalSnapshot::default()
@ -85,6 +86,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot {
plan_mode_active: true,
..PagerLocalSnapshot::default()

View file

@ -184,6 +184,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
let result = cmd.run(&mut ctx, "garbage-input");

View file

@ -61,6 +61,7 @@ mod tests {
session_id: sid,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
match (QueueCommand.run(&mut ctx, ""), sid.is_some()) {

View file

@ -121,6 +121,8 @@ mod tests {
models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: mode,
}
}
@ -136,6 +138,7 @@ mod tests {
session_id: session,
bundle_state: bundle,
screen_mode: mode,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}

View file

@ -54,6 +54,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,

View file

@ -62,6 +62,7 @@ mod tests {
session_id: sid,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
TasksCommand.run(&mut ctx, "")

View file

@ -1,199 +0,0 @@
//! `/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(),
));
// SSH wrap recommendation — rendered as its own section below, NOT an
// issue row: nothing is misconfigured, so it must not put "N issue(s)"
// on every healthy SSH session. On-demand diagnostics also ignore the
// `[ui.contextual_hints].ssh_wrap` tip opt-out: that gate (both its
// user and remote tiers) governs the unprompted session-load tip,
// while here the user explicitly asked for setup guidance, and an
// environment report that omits a known improvement would be
// incomplete.
let ssh_wrap_recommendation = crate::diagnostics::ssh_wrap_hint(
ctx.is_ssh,
crate::clipboard::osc52_sink_active(),
ctx.is_official_vscode_remote,
);
let route = crate::clipboard::clipboard_route();
let is_ssh = xai_grok_shell::util::clipboard::is_remote_session();
let container_no_display =
xai_grok_shell::util::clipboard::is_containerized_without_display();
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 --
let display_server = crate::host::DisplayServer::current();
let is_wayland = display_server == crate::host::DisplayServer::Wayland;
let clipboard_diagnostics = crate::diagnostics::format_clipboard_diagnostics(
crate::diagnostics::ClipboardDiagnosticsInput {
route_native: route.native,
route_tmux: route.tmux_buffer,
route_osc52: route.osc52,
native_tool: xai_grok_shell::util::clipboard::native_tool_name(),
brand: ctx.brand,
host_os: crate::host::HostOs::current(),
display_server,
is_ssh,
container_no_display,
osc52_sink: crate::clipboard::osc52_sink_active(),
wayland_data_control: is_wayland
&& xai_grok_shell::util::clipboard::wayland_data_control_supported(),
wl_copy_available: is_wayland
&& xai_grok_shell::util::clipboard::native_tool_name() == "wl-copy",
},
);
out.push('\n');
out.push_str(&clipboard_diagnostics.text);
// -- Diagnostics --
if warnings.is_empty() && !clipboard_diagnostics.has_issue {
out.push_str("\nNo issues found.\n");
} else if !warnings.is_empty() {
out.push_str(&format!("\n{} additional 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));
}
}
}
// -- Recommendation --
if let Some(rec) = ssh_wrap_recommendation {
out.push_str(&format!("\nRecommendation\n\n {}\n", rec.message));
if let Some(fix) = rec.fix.as_deref() {
out.push_str(&format!(" Run: `{}`\n", fix));
}
if let Some(note) = rec.note.as_deref() {
out.push_str(&format!(" Note: {}\n", note));
}
}
CommandResult::Message(out)
}
}

View file

@ -179,6 +179,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
@ -199,6 +201,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
@ -220,6 +224,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
@ -242,6 +248,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
@ -268,6 +276,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
let items = cmd.suggest_args(&ctx, "").expect("should return items");
@ -297,6 +307,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -338,6 +349,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -368,6 +380,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -396,6 +409,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -502,6 +516,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
@ -529,6 +544,7 @@ mod tests {
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,

View file

@ -64,6 +64,7 @@ mod tests {
session_id: None,
bundle_state: bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
}
}
@ -103,6 +104,8 @@ mod tests {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: true,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
set_enabled(true);

View file

@ -67,6 +67,7 @@ mod tests {
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
match TranscriptCommand.run(&mut ctx, "") {
@ -84,6 +85,7 @@ mod tests {
session_id: Some(&sid),
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
assert!(matches!(

View file

@ -1,13 +1,8 @@
//! `/usage` -- show credit usage or open billing management page.
//! `/usage` — session token/cost; consumer accounts can also manage billing.
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 {
@ -15,15 +10,12 @@ impl SlashCommand for UsageCommand {
"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"
"View usage"
}
fn usage(&self) -> &str {
@ -34,34 +26,42 @@ impl SlashCommand for UsageCommand {
true
}
fn arg_placeholder(&self) -> Option<&str> {
Some("show | manage")
fn takes_args_now(&self, ctx: &AppCtx) -> bool {
// Non-consumer: bare `/usage` only — Enter should send, not chain for args.
ctx.billing_surface_visible
}
fn suggest_args(&self, _ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
fn suggest_args(&self, ctx: &AppCtx, _args_query: &str) -> Option<Vec<ArgItem>> {
if !ctx.billing_surface_visible {
return None;
}
Some(vec![
ArgItem {
display: "show".to_string(),
match_text: "show".to_string(),
insert_text: "show".to_string(),
description: "View credit usage".to_string(),
display: "show".into(),
match_text: "show".into(),
insert_text: "show".into(),
description: "View usage".into(),
},
ArgItem {
display: "manage".to_string(),
match_text: "manage".to_string(),
insert_text: "manage".to_string(),
description: "Open billing management page".to_string(),
display: "manage".into(),
match_text: "manage".into(),
insert_text: "manage".into(),
description: "Manage billing".into(),
},
])
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let arg = args.trim();
if !ctx.billing_surface_visible {
return match arg {
"" => CommandResult::Action(Action::ShowUsage),
_ => CommandResult::Error(format!("Unknown argument: {arg}. Use /usage")),
};
}
match arg {
"" | "show" => CommandResult::Action(Action::ShowUsage),
"manage" => {
CommandResult::Action(Action::OpenUrl("https://grok.com/?_s=usage".to_string()))
}
"manage" => CommandResult::Action(Action::ManageBilling),
_ => CommandResult::Error(format!(
"Unknown argument: {arg}. Use /usage show or /usage manage"
)),

View file

@ -0,0 +1,78 @@
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct WorkflowsCommand;
impl SlashCommand for WorkflowsCommand {
fn name(&self) -> &str {
"workflows"
}
fn description(&self) -> &str {
"Show workflow runs (phases, agents, progress)"
}
fn usage(&self) -> &str {
"/workflows"
}
fn visible(&self, _ctx: &crate::slash::command::AppCtx) -> bool {
true
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::ToggleWorkflows)
}
}
#[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 visibility_is_defensive_during_catalog_reload() {
let models = ModelState::default();
for available in [false, true] {
let ctx = crate::slash::command::AppCtx {
models: &models,
cwd: std::path::Path::new("."),
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: available,
screen_mode: crate::app::ScreenMode::Fullscreen,
};
assert!(WorkflowsCommand.visible(&ctx));
}
}
#[test]
fn dispatches_toggle_workflows() {
let models = ModelState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Minimal,
billing_surface_visible: true,
pager_state: PagerLocalSnapshot::default(),
};
assert!(matches!(
WorkflowsCommand.run(&mut ctx, ""),
CommandResult::Action(Action::ToggleWorkflows)
));
}
}

View file

@ -49,9 +49,9 @@ pub struct SuggestionRow {
}
impl SuggestionRow {
fn from_command(trigger: &CommandTrigger) -> Self {
fn from_command(trigger: &CommandTrigger, takes_args: bool) -> Self {
let mut insert_text = trigger.display.clone();
if trigger.takes_args {
if takes_args {
insert_text.push(' ');
}
Self {
@ -258,6 +258,9 @@ pub struct SlashController {
hide_session_scoped: bool,
/// Offer `/announcements` when session announcements (critical or promo) exist.
has_session_announcements: bool,
/// Consumer billing surface — gates `/usage` subcommands. Default `true`.
billing_surface_visible: bool,
workflows_available: bool,
/// Effective render mode of this process (immutable after startup — it only
/// changes via a full `/minimal`-`/fullscreen` re-exec). Injected via
/// [`Self::set_screen_mode`] wherever prompts are created; gates the
@ -293,6 +296,8 @@ impl SlashController {
cwd,
hide_session_scoped: false,
has_session_announcements: false,
billing_surface_visible: true,
workflows_available: false,
screen_mode: crate::app::ScreenMode::Fullscreen,
mru,
}
@ -313,6 +318,22 @@ impl SlashController {
self.has_session_announcements
}
pub fn set_billing_surface_visible(&mut self, visible: bool) {
self.billing_surface_visible = visible;
}
pub fn billing_surface_visible(&self) -> bool {
self.billing_surface_visible
}
pub fn set_workflows_available(&mut self, available: bool) {
self.workflows_available = available;
}
pub fn workflows_available(&self) -> bool {
self.workflows_available
}
/// Record the process's effective screen mode (see the field doc).
pub(crate) fn set_screen_mode(&mut self, mode: crate::app::ScreenMode) {
self.screen_mode = mode;
@ -327,6 +348,8 @@ impl SlashController {
models,
cwd: &self.cwd,
has_session_announcements: self.has_session_announcements,
billing_surface_visible: self.billing_surface_visible,
workflows_available: self.workflows_available,
screen_mode: self.screen_mode,
}
}
@ -630,11 +653,8 @@ impl SlashController {
else {
return snapshot;
};
let visible = {
let ctx = self.app_ctx(models);
command.visible(&ctx)
};
if !visible || !command.takes_args() {
let ctx = self.app_ctx(models);
if !command.visible(&ctx) || !command.takes_args_now(&ctx) {
return snapshot;
}
@ -822,7 +842,12 @@ impl SlashController {
continue;
}
if seen.insert(trigger.command_index) {
rows.push(SuggestionRow::from_command(trigger));
let takes = self
.registry
.commands_by_index(trigger.command_index)
.map(|cmd| cmd.takes_args_now(&ctx))
.unwrap_or(false);
rows.push(SuggestionRow::from_command(trigger, takes));
}
}
return rows;
@ -851,8 +876,7 @@ impl SlashController {
// Deduplicate: keep the best-scoring trigger per command.
// At equal fuzzy scores the tiebreaker is:
// 1. Exact match on match_text wins (e.g. alias "/m" for query "m")
// 2. Canonical name beats aliases (e.g. "/terminal-setup" over
// "/terminal-check" for query "terminal")
// 2. Canonical name beats aliases
// 3. Lexicographic display order as final fallback
let mut best_per_command: HashMap<usize, (u32, usize)> = HashMap::new();
for (visible_idx, score) in hits {
@ -885,10 +909,22 @@ impl SlashController {
}
let mut deduped: Vec<(u32, usize)> = best_per_command.into_values().collect();
let mut rows: Vec<SuggestionRow> = visible_triggers
.iter()
.map(|t| SuggestionRow::from_command(t))
.collect();
// Re-borrow after rank so takes_args_now can see AppCtx without
// overlapping the matcher mut borrow.
let mut rows: Vec<SuggestionRow> = {
let ctx = self.app_ctx(models);
visible_triggers
.iter()
.map(|t| {
let takes = self
.registry
.commands_by_index(t.command_index)
.map(|cmd| cmd.takes_args_now(&ctx))
.unwrap_or(false);
SuggestionRow::from_command(t, takes)
})
.collect()
};
let sort_meta: Vec<(String, CommandSource)> = visible_triggers
.iter()
.map(|t| (t.canonical.clone(), t.source))
@ -954,10 +990,10 @@ impl SlashController {
models: &ModelState,
query: &str,
) -> Vec<SuggestionRow> {
if !command.takes_args() {
let ctx = self.app_ctx(models);
if !command.takes_args_now(&ctx) {
return Vec::new();
}
let ctx = self.app_ctx(models);
let Some(items) = command.suggest_args(&ctx, query) else {
return Vec::new();
};
@ -1849,6 +1885,7 @@ mod tests {
"/btw",
"/session-info",
"/find",
"/doctor",
] {
assert!(
!names.contains(&hide),
@ -1873,6 +1910,7 @@ mod tests {
.collect();
assert!(names.iter().any(|d| d == "/compact"));
assert!(names.iter().any(|d| d == "/fork"));
assert!(names.iter().any(|d| d == "/doctor"));
}
/// `/cd` is dashboard-only: it appears in the dropdown on the
@ -2658,28 +2696,33 @@ mod tests {
}
#[test]
fn dedup_prefers_canonical_over_alias_at_equal_score() {
// When the query matches both the canonical name and an alias
// equally well, the dropdown should show the canonical name.
// Regression: "terminal" used to show "/terminal-check" (alias)
// instead of "/terminal-setup" (canonical) because the alias
// sorts lexicographically first.
fn doctor_completion_prefers_canonical_but_honors_exact_aliases() {
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
let state = SlashState::default();
let models = ModelState::default();
let text = "/terminal";
let text = "/doctor";
ctrl.refresh(&state, text, text.len(), &models);
let snap = state.snapshot();
assert!(snap.open);
let displays: Vec<&str> = snap.matches.iter().map(|r| r.display.as_str()).collect();
let snapshot = state.snapshot();
let displays: Vec<&str> = snapshot
.matches
.iter()
.map(|row| row.display.as_str())
.collect();
assert!(displays.contains(&"/doctor"), "matches: {displays:?}");
assert!(!displays.contains(&"/terminal-setup"));
let text = "/terminal-setup";
ctrl.refresh(&state, text, text.len(), &models);
let snapshot = state.snapshot();
let displays: Vec<&str> = snapshot
.matches
.iter()
.map(|row| row.display.as_str())
.collect();
assert!(
displays.contains(&"/terminal-setup"),
"expected /terminal-setup (canonical) in matches, got: {displays:?}"
);
assert!(
!displays.contains(&"/terminal-check"),
"/terminal-check (alias) should be deduplicated in favor of canonical"
"matches: {displays:?}"
);
}
}

View file

@ -374,12 +374,6 @@ impl CommandRegistry {
self.set_command_visible("share", visible);
}
/// Show or hide the /usage command.
/// When hidden, it won't appear in the dropdown or be executable.
pub fn set_usage_visible(&mut self, visible: bool) {
self.set_command_visible("usage", visible);
}
/// Show or hide the `/dashboard` command (feature-flag gating).
///
/// The command is hidden by default (see [`Self::new`]) and revealed here
@ -756,35 +750,6 @@ mod tests {
assert!(registry.triggers().iter().any(|t| t.canonical == "share"));
}
#[test]
fn set_usage_visible_hides_and_restores_usage_command() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &[],
});
let other: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![usage, other]);
// Default: /usage is visible.
assert!(registry.get("usage").is_some());
assert!(registry.triggers().iter().any(|t| t.canonical == "usage"));
// Hiding /usage removes it from lookup and triggers.
registry.set_usage_visible(false);
assert!(registry.get("usage").is_none());
assert!(!registry.triggers().iter().any(|t| t.canonical == "usage"));
// Other commands are unaffected.
assert!(registry.get("exit").is_some());
// Re-enabling restores it.
registry.set_usage_visible(true);
assert!(registry.get("usage").is_some());
assert!(registry.triggers().iter().any(|t| t.canonical == "usage"));
}
#[test]
fn restricted_commands_hide_and_restore() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
@ -870,17 +835,17 @@ mod tests {
#[test]
fn restricted_wins_over_visible_setters() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
let share: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "share",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![usage]);
let mut registry = CommandRegistry::new(vec![share]);
registry.set_restricted_commands(&["usage".to_string()]);
// A later `set_usage_visible(true)` (auth-meta path) must NOT
// resurrect a restricted command.
registry.set_usage_visible(true);
assert!(registry.get("usage").is_none());
registry.set_restricted_commands(&["share".to_string()]);
// A later `set_share_visible(true)` must NOT resurrect a
// restricted command — deny wins over every visibility gate.
registry.set_share_visible(true);
assert!(registry.get("share").is_none());
}
#[test]