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:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
50
crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs
Normal file
50
crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
129
crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs
Normal file
129
crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs
Normal 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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ mod tests {
|
|||
models,
|
||||
session_id,
|
||||
bundle_state: &DEFAULT_BUNDLE_STATE,
|
||||
billing_surface_visible: true,
|
||||
screen_mode,
|
||||
pager_state: PagerLocalSnapshot::default(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue