Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
2958 lines
108 KiB
Rust
2958 lines
108 KiB
Rust
//! Slash command system -- prompt-centric inline completion and execution.
|
|
//!
|
|
//! Pager's synchronous dispatch model. Key components:
|
|
//!
|
|
//! - [`SlashController`] -- derives completion state from prompt text + cursor.
|
|
//! - [`SlashState`] / [`SlashSnapshot`] -- snapshot holder for rendering.
|
|
//! - [`parse_invocation()`] -- extracts command token + args from input.
|
|
//! - [`is_command_complete()`] -- two-bit completeness model.
|
|
//! - [`CommandRegistry`] -- maps names/aliases to command implementations.
|
|
|
|
pub mod acp_command;
|
|
pub mod command;
|
|
pub mod commands;
|
|
pub mod matcher;
|
|
pub mod mru;
|
|
pub mod registry;
|
|
|
|
use std::{
|
|
cell::RefCell,
|
|
collections::{HashMap, HashSet},
|
|
ops::Range,
|
|
};
|
|
|
|
use crate::acp::model_state::ModelState;
|
|
|
|
use matcher::FuzzyMatcher;
|
|
use registry::{CommandRegistry, CommandSource, CommandTrigger};
|
|
|
|
pub use command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, SlashCommand};
|
|
|
|
/// Maximum number of visible rows in the dropdown (scroll beyond this).
|
|
pub const MAX_VISIBLE_SUGGESTIONS: usize = 6;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SuggestionRow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A single row in the slash suggestion dropdown.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SuggestionRow {
|
|
/// Display text (e.g., "/model" or "Grok 4 Fast").
|
|
pub display: String,
|
|
/// Description text (e.g., "Switch the active model").
|
|
pub description: String,
|
|
/// Text to insert into the prompt on acceptance.
|
|
pub insert_text: String,
|
|
/// Character positions for fuzzy match highlighting.
|
|
pub indices: Vec<u32>,
|
|
/// Free-form bracketed tag (e.g. "new") from the resolved tag map. `None`
|
|
/// for untagged command rows and always `None` for arg rows.
|
|
pub tag: Option<String>,
|
|
}
|
|
|
|
impl SuggestionRow {
|
|
fn from_command(trigger: &CommandTrigger, takes_args: bool) -> Self {
|
|
let mut insert_text = trigger.display.clone();
|
|
if takes_args {
|
|
insert_text.push(' ');
|
|
}
|
|
Self {
|
|
display: trigger.display.clone(),
|
|
description: trigger.description.clone(),
|
|
insert_text,
|
|
indices: Vec::new(),
|
|
tag: None,
|
|
}
|
|
}
|
|
|
|
fn from_arg(item: &ArgItem) -> Self {
|
|
Self {
|
|
display: item.display.clone(),
|
|
description: item.description.clone(),
|
|
insert_text: item.insert_text.clone(),
|
|
indices: Vec::new(),
|
|
tag: None,
|
|
}
|
|
}
|
|
|
|
/// Bare command name from a command-row `display` (strips leading `/`).
|
|
pub(crate) fn command_name(&self) -> &str {
|
|
self.display.strip_prefix('/').unwrap_or(&self.display)
|
|
}
|
|
}
|
|
|
|
/// Prefix match aligned with nucleo `CaseMatching::Smart`: all-lowercase query
|
|
/// is case-insensitive; any uppercase in the query requires exact prefix.
|
|
fn command_prefix_matches_smart(full_name: &str, query: &str) -> bool {
|
|
if query.is_empty() {
|
|
return false;
|
|
}
|
|
let case_sensitive = query.chars().any(|c| c.is_uppercase());
|
|
if case_sensitive {
|
|
return full_name.starts_with(query);
|
|
}
|
|
let mut full_chars = full_name.chars();
|
|
for qc in query.chars() {
|
|
match full_chars.next() {
|
|
Some(fc) if chars_eq_ignore_case(fc, qc) => {}
|
|
_ => return false,
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn chars_eq_ignore_case(a: char, b: char) -> bool {
|
|
a == b || a.eq_ignore_ascii_case(&b)
|
|
}
|
|
|
|
/// Ghost suffix from the selected dropdown row (same ranker as Tab).
|
|
fn inline_ghost_from_selected_command(
|
|
query: &str,
|
|
token_range: Range<usize>,
|
|
row: &SuggestionRow,
|
|
) -> Option<InlineGhost> {
|
|
let full_name = row.command_name();
|
|
if query.is_empty() || !command_prefix_matches_smart(full_name, query) {
|
|
return None;
|
|
}
|
|
let mut rest = full_name.chars();
|
|
for _ in query.chars() {
|
|
rest.next()?;
|
|
}
|
|
let text: String = rest.collect();
|
|
if text.is_empty() {
|
|
return None;
|
|
}
|
|
Some(InlineGhost {
|
|
text,
|
|
token_range,
|
|
full_name: full_name.to_string(),
|
|
})
|
|
}
|
|
|
|
fn sync_inline_ghost_to_selection(inner: &mut SlashSnapshot) {
|
|
if !inner.cursor_in_command || inner.command_recognized {
|
|
return;
|
|
}
|
|
let Some(range) = inner.command_range.clone() else {
|
|
return;
|
|
};
|
|
inner.inline_ghost = inner
|
|
.selection()
|
|
.and_then(|row| inline_ghost_from_selected_command(&inner.query, range, row));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SlashSnapshot / SlashState
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Immutable snapshot of the slash completion state.
|
|
///
|
|
/// Produced by `SlashController::refresh()`, consumed by the dropdown
|
|
/// renderer. Cloned on read (cheap -- small vecs).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SlashSnapshot {
|
|
/// Whether the input looks like a slash command (starts with `/`).
|
|
pub active: bool,
|
|
/// Whether the suggestion dropdown should be visible.
|
|
pub open: bool,
|
|
/// Current query text (command name part, without `/`).
|
|
pub query: String,
|
|
/// Matching suggestion rows.
|
|
pub matches: Vec<SuggestionRow>,
|
|
/// Currently selected index in `matches`.
|
|
pub selected: usize,
|
|
/// Byte range of the command part in the input (e.g., `0..6` for `/model`).
|
|
pub command_range: Option<Range<usize>>,
|
|
/// Byte range of the arguments part in the input.
|
|
pub args_range: Option<Range<usize>>,
|
|
/// Whether the cursor is in the command part (vs args part).
|
|
pub cursor_in_command: bool,
|
|
/// Placeholder text for args (e.g., "[context]").
|
|
pub args_placeholder: Option<String>,
|
|
/// Whether the args query is empty (for placeholder display).
|
|
pub args_query_is_empty: bool,
|
|
/// Whether the resolved command is a skill (for accent color theming).
|
|
pub is_skill: bool,
|
|
/// Whether the command token resolves to a known command in the registry.
|
|
pub command_recognized: bool,
|
|
/// Mid-text inline completion ghost text (for `/` tokens not at position 0).
|
|
pub inline_ghost: Option<InlineGhost>,
|
|
/// Byte ranges of recognized mid-text `/command` tokens (for teal highlighting).
|
|
pub recognized_tokens: Vec<Range<usize>>,
|
|
}
|
|
|
|
/// Ghost text state for a mid-text `/command` token under the cursor.
|
|
#[derive(Debug, Clone)]
|
|
pub struct InlineGhost {
|
|
/// The ghost suffix to render (e.g., "it" when the user typed "/comm" and best match is "commit").
|
|
pub text: String,
|
|
/// Byte range of the partial token being completed (including `/`).
|
|
pub token_range: Range<usize>,
|
|
/// The full command name to insert on Tab accept (without `/`).
|
|
pub full_name: String,
|
|
}
|
|
|
|
impl SlashSnapshot {
|
|
/// The currently selected suggestion row, if any.
|
|
pub fn selection(&self) -> Option<&SuggestionRow> {
|
|
if self.matches.is_empty() {
|
|
None
|
|
} else {
|
|
let idx = self.selected.min(self.matches.len() - 1);
|
|
self.matches.get(idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mutable holder for [`SlashSnapshot`].
|
|
///
|
|
/// Uses `RefCell` for interior mutability -- the controller writes it,
|
|
/// the renderer reads it. Not a trait, just a state container.
|
|
#[derive(Debug, Default)]
|
|
pub struct SlashState {
|
|
inner: RefCell<SlashSnapshot>,
|
|
}
|
|
|
|
impl SlashState {
|
|
/// Clone the current snapshot.
|
|
pub fn snapshot(&self) -> SlashSnapshot {
|
|
self.inner.borrow().clone()
|
|
}
|
|
|
|
/// Replace the entire snapshot.
|
|
pub fn replace(&self, snapshot: SlashSnapshot) {
|
|
*self.inner.borrow_mut() = snapshot;
|
|
}
|
|
|
|
/// Mutate the snapshot in place.
|
|
pub fn update(&self, f: impl FnOnce(&mut SlashSnapshot)) {
|
|
let mut guard = self.inner.borrow_mut();
|
|
f(&mut guard);
|
|
}
|
|
|
|
/// Close the dropdown.
|
|
pub fn close(&self) {
|
|
self.update(|inner| {
|
|
inner.open = false;
|
|
inner.matches.clear();
|
|
inner.args_range = None;
|
|
inner.args_placeholder = None;
|
|
inner.args_query_is_empty = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SlashController
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Derives slash completion state from prompt text + cursor.
|
|
///
|
|
/// Owns a `CommandRegistry` (mutable for ACP sync) and a `FuzzyMatcher`.
|
|
/// The prompt widget calls `refresh()` on every text change.
|
|
pub struct SlashController {
|
|
registry: CommandRegistry,
|
|
matcher: FuzzyMatcher,
|
|
cwd: std::path::PathBuf,
|
|
/// When `true`, commands whose [`SlashCommand::session_scoped`] is
|
|
/// `true` are suppressed from completion. Set on session-less
|
|
/// surfaces — the agent dashboard's dispatch input — so the dropdown
|
|
/// only offers pager-global commands. Defaults to `false`.
|
|
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
|
|
/// screen-mode-switcher commands' visibility through [`AppCtx`]. Defaults
|
|
/// to `Fullscreen` (the process default) for tests and unwired surfaces.
|
|
screen_mode: crate::app::ScreenMode,
|
|
/// MRU/recency store. Owned by `AppView` in production and injected via
|
|
/// [`Self::set_mru`] so agent prompts and the dashboard share one store;
|
|
/// defaults to an isolated in-memory store (no disk I/O) for tests and any
|
|
/// surface that has not been wired up.
|
|
mru: std::rc::Rc<std::cell::RefCell<mru::SlashMru>>,
|
|
/// Resolved per-command tag map (canonical name → free-form tag). Owned by
|
|
/// `AppView` and injected via [`Self::set_command_tags`] so agent prompts
|
|
/// and the dashboard share one map; defaults to empty for tests and any
|
|
/// surface that has not been wired up.
|
|
command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>,
|
|
}
|
|
|
|
impl SlashController {
|
|
/// Create a new controller with the given registry and working directory.
|
|
///
|
|
/// The MRU store defaults to an isolated, in-memory (non-persisting) store.
|
|
/// Production injects the shared store via [`Self::set_mru`].
|
|
pub fn new(registry: CommandRegistry, cwd: std::path::PathBuf) -> Self {
|
|
let mru = std::rc::Rc::new(std::cell::RefCell::new(mru::SlashMru::new_in_memory()));
|
|
Self::with_mru(registry, cwd, mru)
|
|
}
|
|
|
|
/// Controller with an explicit MRU store (tests/production injection).
|
|
pub fn with_mru(
|
|
registry: CommandRegistry,
|
|
cwd: std::path::PathBuf,
|
|
mru: std::rc::Rc<std::cell::RefCell<mru::SlashMru>>,
|
|
) -> Self {
|
|
Self {
|
|
registry,
|
|
matcher: FuzzyMatcher::new(),
|
|
cwd,
|
|
hide_session_scoped: false,
|
|
has_session_announcements: false,
|
|
billing_surface_visible: true,
|
|
workflows_available: false,
|
|
screen_mode: crate::app::ScreenMode::Fullscreen,
|
|
mru,
|
|
command_tags: std::rc::Rc::new(std::cell::RefCell::new(
|
|
std::collections::HashMap::new(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Replace the MRU store with a shared one. Used by `AppView` to inject the
|
|
/// process-wide store into agent prompts and the dashboard dispatch input.
|
|
pub fn set_mru(&mut self, mru: std::rc::Rc<std::cell::RefCell<mru::SlashMru>>) {
|
|
self.mru = mru;
|
|
}
|
|
|
|
/// Replace the per-command tag map with a shared one. Used by `AppView` to
|
|
/// inject the resolved (remote + local) tag map into agent prompts and the
|
|
/// dashboard dispatch input.
|
|
pub fn set_command_tags(
|
|
&mut self,
|
|
command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>,
|
|
) {
|
|
self.command_tags = command_tags;
|
|
}
|
|
|
|
/// Gate `/announcements` on presence of session announcements (critical or promo).
|
|
pub fn set_has_session_announcements(&mut self, has: bool) {
|
|
self.has_session_announcements = has;
|
|
}
|
|
|
|
pub fn has_session_announcements(&self) -> bool {
|
|
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;
|
|
}
|
|
|
|
pub(crate) fn screen_mode(&self) -> crate::app::ScreenMode {
|
|
self.screen_mode
|
|
}
|
|
|
|
pub(crate) fn app_ctx<'a>(&'a self, models: &'a ModelState) -> AppCtx<'a> {
|
|
AppCtx {
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// Last-used timestamp for a command (0 = never). Test diagnostics.
|
|
#[cfg(test)]
|
|
fn mru_last_used(&mut self, prefix: &str, command_name: &str) -> u64 {
|
|
self.mru.borrow_mut().last_used(prefix, command_name)
|
|
}
|
|
|
|
/// Record accept/submit for MRU (canonical command only; typed prefix
|
|
/// ignored). Runs on the UI thread; the resulting snapshot (if any) is
|
|
/// handed to the off-thread serialized writer so the UI never blocks on
|
|
/// disk and writes can't reorder.
|
|
pub fn record_command_use(&mut self, prefix: &str, command_name: &str) {
|
|
let key = command_name.trim().trim_start_matches('/');
|
|
if key.is_empty() {
|
|
return;
|
|
}
|
|
// Dispatch-tier lookup (menu-only hide; see
|
|
// `CommandRegistry::get_for_dispatch`) so menu-hidden submissions
|
|
// canonicalize (alias → name) for MRU like any other.
|
|
let canonical = self
|
|
.registry
|
|
.get_for_dispatch(key)
|
|
.map(|cmd| cmd.name().to_string())
|
|
.unwrap_or_else(|| key.to_string());
|
|
let snapshot = {
|
|
let mut m = self.mru.borrow_mut();
|
|
m.touch(prefix, &canonical);
|
|
m.take_persist_snapshot()
|
|
};
|
|
if let Some(snapshot) = snapshot
|
|
&& !mru::persist_async(snapshot)
|
|
{
|
|
// No write could be attempted (writer unavailable and the sync
|
|
// fallback failed): keep the changes dirty so the next record
|
|
// retries instead of silently dropping them.
|
|
self.mru.borrow_mut().mark_dirty();
|
|
}
|
|
}
|
|
|
|
/// Create a controller pre-loaded with pager builtin commands.
|
|
pub fn with_builtins(cwd: std::path::PathBuf) -> Self {
|
|
Self::new(CommandRegistry::new(commands::builtin_commands()), cwd)
|
|
}
|
|
|
|
/// Mutable access to the registry (for ACP sync).
|
|
pub fn registry_mut(&mut self) -> &mut CommandRegistry {
|
|
&mut self.registry
|
|
}
|
|
|
|
/// Immutable access to the registry (for dispatch lookup).
|
|
pub fn registry(&self) -> &CommandRegistry {
|
|
&self.registry
|
|
}
|
|
|
|
/// Gate `/auto` on the auto permission-mode feature. When unavailable,
|
|
/// `/auto` is hard-hidden. `/always-approve` is always offered; both
|
|
/// commands are true toggles (re-running the active mode turns it off).
|
|
pub fn set_auto_mode_available(&mut self, available: bool) {
|
|
self.registry.set_auto_mode_available(available);
|
|
}
|
|
|
|
/// Suppress (or restore) session-scoped commands in completion.
|
|
///
|
|
/// Called once on session-less surfaces (the agent dashboard's
|
|
/// dispatch input) so commands that act on a single session never
|
|
/// surface in the dropdown or inline ghost. See
|
|
/// [`SlashCommand::session_scoped`].
|
|
pub fn set_hide_session_scoped(&mut self, hide: bool) {
|
|
self.hide_session_scoped = hide;
|
|
}
|
|
|
|
/// Whether session-scoped commands are suppressed on this surface
|
|
/// (see [`Self::set_hide_session_scoped`]).
|
|
pub fn hide_session_scoped(&self) -> bool {
|
|
self.hide_session_scoped
|
|
}
|
|
|
|
/// Whether `command` should be offered for completion or execution
|
|
/// given this controller's session-scope policy and the command's
|
|
/// own visibility gates. See [`command_offered`].
|
|
pub fn is_command_offered(&self, command: &dyn SlashCommand, models: &ModelState) -> bool {
|
|
let ctx = self.app_ctx(models);
|
|
command_offered(command, &ctx, self.hide_session_scoped)
|
|
}
|
|
|
|
/// Recompute the snapshot from prompt text + cursor position.
|
|
pub fn refresh(&mut self, slash: &SlashState, text: &str, cursor: usize, models: &ModelState) {
|
|
let previous = slash.snapshot();
|
|
let inline_tokens = scan_inline_slash_tokens(text, cursor);
|
|
let leading = analyze_input(text, cursor);
|
|
|
|
// Mid-text `/token` under the cursor, or args after that token (before the
|
|
// next `/token`). Uses token-local ranges instead of buffer-start spans.
|
|
if let Some(phase) = mid_text_slash_context(text, cursor, &inline_tokens)
|
|
&& should_use_mid_text_refresh(&phase, leading.as_ref(), cursor)
|
|
{
|
|
let token = match &phase {
|
|
MidTextSlashPhase::Command(t) | MidTextSlashPhase::Args(t) => t,
|
|
};
|
|
self.refresh_mid_text_slash(MidTextSlashRefresh {
|
|
slash,
|
|
text,
|
|
cursor,
|
|
token,
|
|
phase,
|
|
all_tokens: &inline_tokens,
|
|
models,
|
|
previous: &previous,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let Some(input) = leading else {
|
|
// Text doesn't start with `/` -- check for mid-text slash tokens.
|
|
let inline = self.compute_inline_slash(text, models);
|
|
slash.replace(inline);
|
|
return;
|
|
};
|
|
|
|
let args_text_empty = input
|
|
.args_range
|
|
.as_ref()
|
|
.is_some_and(|r| text[r.start..r.end].trim().is_empty());
|
|
let mut snapshot = SlashSnapshot {
|
|
active: true,
|
|
open: false,
|
|
query: input.query.clone(),
|
|
matches: Vec::new(),
|
|
selected: 0,
|
|
command_range: Some(input.command_range.clone()),
|
|
args_range: input.args_range.clone(),
|
|
cursor_in_command: input.cursor_in_command,
|
|
args_placeholder: None,
|
|
args_query_is_empty: args_text_empty,
|
|
is_skill: false,
|
|
command_recognized: false,
|
|
inline_ghost: None,
|
|
recognized_tokens: Vec::new(),
|
|
};
|
|
|
|
// Cursor inside the command token opens the command menu even when
|
|
// args follow (e.g. `/` typed at the start of existing text via
|
|
// ctrl-a) — same as mid-text tokens. The query is cursor-clamped, so
|
|
// `/` before existing text shows the full list like an empty composer.
|
|
// The two branches partition: analyze_input sets args_range exactly
|
|
// when the cursor is past the command token.
|
|
if input.cursor_in_command {
|
|
let matches = self.command_suggestions(&input.query, models);
|
|
snapshot.selected = Self::carry_selection(&previous, &matches, true, &input);
|
|
snapshot.open = !matches.is_empty();
|
|
snapshot.matches = matches;
|
|
} else if input.args_range.is_some() {
|
|
let matches = self.arg_suggestions_for_input(text, &input, models);
|
|
snapshot.selected = Self::carry_selection(&previous, &matches, false, &input);
|
|
snapshot.open = !matches.is_empty();
|
|
snapshot.matches = matches;
|
|
}
|
|
|
|
// Resolve the command for args placeholder and skill detection.
|
|
// Dispatch-tier lookup (menu-only hide; see
|
|
// `CommandRegistry::get_for_dispatch`): menu-hidden commands still
|
|
// execute on Enter, so the composer must render them as recognized.
|
|
if let Some(invocation) = parse_invocation(text)
|
|
&& let Some(command) = self.registry.get_for_dispatch(invocation.token)
|
|
{
|
|
let ctx = self.app_ctx(models);
|
|
if command_offered(command.as_ref(), &ctx, self.hide_session_scoped) {
|
|
snapshot.command_recognized = true;
|
|
snapshot.is_skill = command.is_skill();
|
|
if args_text_empty {
|
|
snapshot.args_placeholder = command.arg_placeholder().map(|s| s.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also scan for mid-text slash tokens (after the first one) so that
|
|
// prompts like "/model foo /comm" get ghost text and teal highlighting
|
|
// on the second and subsequent `/` tokens.
|
|
// compute_inline_slash only supplies recognized-token highlights now;
|
|
// the inline ghost is derived solely from the dropdown selection (one
|
|
// ranker, shared with Tab) via sync_inline_ghost_to_selection below.
|
|
let inline = self.compute_inline_slash(text, models);
|
|
snapshot.recognized_tokens = inline.recognized_tokens;
|
|
sync_inline_ghost_to_selection(&mut snapshot);
|
|
|
|
slash.replace(snapshot);
|
|
}
|
|
|
|
/// Slash completion for a mid-text `/token` (command or args phase).
|
|
fn refresh_mid_text_slash(&mut self, p: MidTextSlashRefresh<'_>) {
|
|
let MidTextSlashRefresh {
|
|
slash,
|
|
text,
|
|
cursor,
|
|
token,
|
|
phase,
|
|
all_tokens,
|
|
models,
|
|
previous,
|
|
} = p;
|
|
// Drop app_ctx before any &mut self call (it borrows self.cwd).
|
|
// Same gate as the leading-`/` path and recognized_token_ranges, so
|
|
// the under-cursor teal (command_recognized) can't disagree with the
|
|
// token-range highlight on scope-restricted surfaces.
|
|
let is_recognized = {
|
|
let ctx = self.app_ctx(models);
|
|
self.registry
|
|
.get(&token.name)
|
|
.is_some_and(|cmd| command_offered(cmd.as_ref(), &ctx, self.hide_session_scoped))
|
|
};
|
|
|
|
let mut snapshot = match phase {
|
|
MidTextSlashPhase::Args(_) => {
|
|
self.refresh_mid_text_slash_args(MidTextSlashArgsRefresh {
|
|
text,
|
|
cursor,
|
|
token,
|
|
all_tokens,
|
|
models,
|
|
is_recognized,
|
|
previous,
|
|
})
|
|
}
|
|
MidTextSlashPhase::Command(_) => {
|
|
let matches = self.command_suggestions(&token.name, models);
|
|
let input = SlashInput {
|
|
command_range: token.range.clone(),
|
|
query: token.name.clone(),
|
|
cursor_in_command: true,
|
|
args_range: None,
|
|
args_query: String::new(),
|
|
};
|
|
let selected = Self::carry_selection(previous, &matches, true, &input);
|
|
SlashSnapshot {
|
|
active: true,
|
|
open: !matches.is_empty(),
|
|
query: token.name.clone(),
|
|
matches,
|
|
selected,
|
|
command_range: Some(token.range.clone()),
|
|
args_range: None,
|
|
cursor_in_command: true,
|
|
args_placeholder: None,
|
|
args_query_is_empty: true,
|
|
is_skill: false,
|
|
command_recognized: is_recognized,
|
|
inline_ghost: None,
|
|
recognized_tokens: Vec::new(),
|
|
}
|
|
}
|
|
};
|
|
|
|
if is_recognized && let Some(command) = self.registry.get(&token.name) {
|
|
snapshot.is_skill = command.is_skill();
|
|
}
|
|
|
|
// Same membership rule as every other composer state (and the
|
|
// submit-time capture), so the highlight can't flicker with cursor
|
|
// position or diverge from the echo's ranges.
|
|
snapshot.recognized_tokens = self.recognized_token_ranges(text, models);
|
|
|
|
// Same invariant as leading `/` and arrow nav: ghost completes selected row only.
|
|
sync_inline_ghost_to_selection(&mut snapshot);
|
|
|
|
slash.replace(snapshot);
|
|
}
|
|
|
|
fn refresh_mid_text_slash_args(&mut self, p: MidTextSlashArgsRefresh<'_>) -> SlashSnapshot {
|
|
let MidTextSlashArgsRefresh {
|
|
text,
|
|
cursor,
|
|
token,
|
|
all_tokens,
|
|
models,
|
|
is_recognized,
|
|
previous,
|
|
} = p;
|
|
let mut snapshot = SlashSnapshot {
|
|
active: true,
|
|
open: false,
|
|
query: token.name.clone(),
|
|
matches: Vec::new(),
|
|
selected: 0,
|
|
command_range: Some(token.range.clone()),
|
|
args_range: None,
|
|
cursor_in_command: false,
|
|
args_placeholder: None,
|
|
args_query_is_empty: true,
|
|
is_skill: false,
|
|
command_recognized: is_recognized,
|
|
inline_ghost: None,
|
|
recognized_tokens: Vec::new(),
|
|
};
|
|
|
|
let Some(command) = is_recognized
|
|
.then(|| self.registry.get(&token.name).cloned())
|
|
.flatten()
|
|
else {
|
|
return snapshot;
|
|
};
|
|
let ctx = self.app_ctx(models);
|
|
if !command.visible(&ctx) || !command.takes_args_now(&ctx) {
|
|
return snapshot;
|
|
}
|
|
|
|
let token_with_slash = &text[token.range.start..token.range.end];
|
|
if parse_invocation(token_with_slash).is_none() {
|
|
return snapshot;
|
|
}
|
|
|
|
let args_start = token.range.end;
|
|
if args_start >= text.len()
|
|
|| !text[args_start..]
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|ch| ch.is_whitespace())
|
|
{
|
|
return snapshot;
|
|
}
|
|
|
|
let mut start = args_start;
|
|
while start < text.len() {
|
|
let ch = match text[start..].chars().next() {
|
|
Some(ch) => ch,
|
|
None => break,
|
|
};
|
|
if ch.is_whitespace() {
|
|
start += ch.len_utf8();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
let args_end = next_slash_token_start(all_tokens, token).unwrap_or(text.len());
|
|
let args_empty = start >= args_end || text[start..args_end].trim().is_empty();
|
|
let args_query = if cursor > start {
|
|
text[start..cursor.min(args_end)].to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let arg_matches = self.arg_suggestions(command.as_ref(), models, &args_query);
|
|
let args_range = Some(start..args_end);
|
|
let input = SlashInput {
|
|
command_range: token.range.clone(),
|
|
query: token.name.clone(),
|
|
cursor_in_command: false,
|
|
args_range: args_range.clone(),
|
|
args_query: args_query.clone(),
|
|
};
|
|
|
|
snapshot.args_query_is_empty = args_empty;
|
|
snapshot.args_range = args_range;
|
|
snapshot.open = !arg_matches.is_empty();
|
|
snapshot.matches = arg_matches;
|
|
snapshot.selected = Self::carry_selection(previous, &snapshot.matches, false, &input);
|
|
if args_empty {
|
|
snapshot.args_placeholder = command.arg_placeholder().map(|s| s.to_string());
|
|
}
|
|
|
|
snapshot
|
|
}
|
|
|
|
/// Move the dropdown selection by `delta` (positive = down, negative = up),
|
|
/// wrapping around at the ends. Used for keyboard arrow / Ctrl-P/N nav.
|
|
pub fn move_selection(&self, slash: &SlashState, delta: isize) {
|
|
slash.update(|inner| {
|
|
let len = inner.matches.len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
let current = inner.selected.min(len - 1) as isize;
|
|
let next = (current + delta).rem_euclid(len as isize) as usize;
|
|
inner.selected = next;
|
|
sync_inline_ghost_to_selection(inner);
|
|
});
|
|
}
|
|
|
|
/// Move the dropdown selection by `delta`, clamping at the first/last item
|
|
/// (no wrap-around). Used for mouse-wheel scrolling.
|
|
pub fn scroll_selection(&self, slash: &SlashState, delta: isize) {
|
|
slash.update(|inner| {
|
|
let len = inner.matches.len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
let current = inner.selected.min(len - 1) as isize;
|
|
let next = (current + delta).clamp(0, len as isize - 1) as usize;
|
|
inner.selected = next;
|
|
sync_inline_ghost_to_selection(inner);
|
|
});
|
|
}
|
|
|
|
/// Try to carry the previous selection across a refresh.
|
|
fn carry_selection(
|
|
previous: &SlashSnapshot,
|
|
matches: &[SuggestionRow],
|
|
cursor_in_command: bool,
|
|
input: &SlashInput,
|
|
) -> usize {
|
|
if matches.is_empty() {
|
|
return 0;
|
|
}
|
|
|
|
let same_context = if cursor_in_command {
|
|
previous.cursor_in_command && previous.query == input.query
|
|
} else {
|
|
!previous.cursor_in_command && previous.args_range == input.args_range
|
|
};
|
|
if !same_context || previous.matches.is_empty() {
|
|
return 0;
|
|
}
|
|
|
|
let prev_idx = previous
|
|
.selected
|
|
.min(previous.matches.len().saturating_sub(1));
|
|
if let Some(prev_row) = previous.matches.get(prev_idx)
|
|
&& let Some(pos) = matches
|
|
.iter()
|
|
.position(|row| row.insert_text == prev_row.insert_text)
|
|
{
|
|
return pos;
|
|
}
|
|
|
|
previous.selected.min(matches.len().saturating_sub(1))
|
|
}
|
|
|
|
/// Byte ranges of recognized `/command` tokens anywhere in `text`.
|
|
///
|
|
/// Single source of truth for the composer's teal token highlighting AND
|
|
/// the scrollback echo of submitted prompts: whitespace-preceded `/{word}`
|
|
/// tokens (see [`scan_inline_slash_tokens`]) whose name resolves to a
|
|
/// command that is offered on this surface ([`command_offered`]).
|
|
/// Cursor-independent. Empty when nothing is recognized.
|
|
pub fn recognized_token_ranges(&self, text: &str, models: &ModelState) -> Vec<Range<usize>> {
|
|
let tokens = scan_inline_slash_tokens(text, 0);
|
|
if tokens.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let ctx = self.app_ctx(models);
|
|
let hide_session = self.hide_session_scoped;
|
|
tokens
|
|
.into_iter()
|
|
.filter(|token| {
|
|
self.registry
|
|
.get(&token.name)
|
|
.is_some_and(|cmd| command_offered(cmd.as_ref(), &ctx, hide_session))
|
|
})
|
|
.map(|token| token.range)
|
|
.collect()
|
|
}
|
|
|
|
/// Compute inline slash state for text that doesn't start with `/`.
|
|
///
|
|
/// Recognized-token highlights only ([`Self::recognized_token_ranges`]).
|
|
/// Ghost for partial commands comes solely from
|
|
/// [`sync_inline_ghost_to_selection`] (dropdown selection).
|
|
fn compute_inline_slash(&self, text: &str, models: &ModelState) -> SlashSnapshot {
|
|
SlashSnapshot {
|
|
recognized_tokens: self.recognized_token_ranges(text, models),
|
|
..SlashSnapshot::default()
|
|
}
|
|
}
|
|
|
|
/// Generate command-level suggestions for a query.
|
|
///
|
|
/// Filters out any command whose `visible(&AppCtx)` returns `false`.
|
|
fn command_suggestions(&mut self, query: &str, models: &ModelState) -> Vec<SuggestionRow> {
|
|
let ctx = self.app_ctx(models);
|
|
let hide_session = self.hide_session_scoped;
|
|
let visible_indices: HashSet<usize> = (0..self.registry.triggers().len())
|
|
.filter(|i| {
|
|
let trigger = &self.registry.triggers()[*i];
|
|
self.registry
|
|
.commands_by_index(trigger.command_index)
|
|
.is_some_and(|cmd| command_offered(cmd.as_ref(), &ctx, hide_session))
|
|
})
|
|
.collect();
|
|
let triggers = self.registry.triggers();
|
|
let trimmed = query.trim();
|
|
if trimmed.is_empty() {
|
|
// Show all unique commands (deduplicate by command_index).
|
|
// No cap here -- the dropdown renderer handles scrolling.
|
|
let mut seen = HashSet::new();
|
|
let mut rows = Vec::new();
|
|
// Retain canonicals so tags are set in a second pass, keeping the
|
|
// `takes_args_now` command callback outside any tag-map borrow.
|
|
let mut canonicals: Vec<&str> = Vec::new();
|
|
for (i, trigger) in triggers.iter().enumerate() {
|
|
if !visible_indices.contains(&i) {
|
|
continue;
|
|
}
|
|
if seen.insert(trigger.command_index) {
|
|
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));
|
|
canonicals.push(trigger.canonical.as_str());
|
|
}
|
|
}
|
|
// Tag from the data map in one scoped borrow; key off canonical
|
|
// (never the alias/display).
|
|
{
|
|
let command_tags = self.command_tags.borrow();
|
|
for (row, canonical) in rows.iter_mut().zip(canonicals.iter()) {
|
|
row.tag = command_tags.get(*canonical).cloned();
|
|
}
|
|
}
|
|
// Surface tagged commands (curated new/beta) at the top of the bare "/" menu;
|
|
// stable so registry order is preserved within the tagged and untagged groups.
|
|
rows.sort_by_key(|r| r.tag.is_none());
|
|
return rows;
|
|
}
|
|
|
|
// Reject double-slash sequences.
|
|
if trimmed.contains('/') {
|
|
return Vec::new();
|
|
}
|
|
|
|
// Restrict the matcher to the visible subset so hidden commands
|
|
// never show up in fuzzy results.
|
|
let visible_triggers: Vec<&CommandTrigger> = triggers
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, _)| visible_indices.contains(i))
|
|
.map(|(_, t)| t)
|
|
.collect();
|
|
let hits = self.matcher.rank(
|
|
&visible_triggers,
|
|
trimmed,
|
|
visible_triggers.len(),
|
|
|trigger| trigger.match_text.as_str(),
|
|
);
|
|
|
|
// 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
|
|
// 3. Lexicographic display order as final fallback
|
|
let mut best_per_command: HashMap<usize, (u32, usize)> = HashMap::new();
|
|
for (visible_idx, score) in hits {
|
|
let trigger = visible_triggers[visible_idx];
|
|
best_per_command
|
|
.entry(trigger.command_index)
|
|
.and_modify(|current| {
|
|
let dominated = if score != current.0 {
|
|
score > current.0
|
|
} else {
|
|
let new_exact = trigger.match_text == trimmed;
|
|
let cur_exact = visible_triggers[current.1].match_text == trimmed;
|
|
if new_exact != cur_exact {
|
|
new_exact
|
|
} else {
|
|
let new_canonical = trigger.alias.is_none();
|
|
let cur_canonical = visible_triggers[current.1].alias.is_none();
|
|
if new_canonical != cur_canonical {
|
|
new_canonical
|
|
} else {
|
|
trigger.display < visible_triggers[current.1].display
|
|
}
|
|
}
|
|
};
|
|
if dominated {
|
|
*current = (score, visible_idx);
|
|
}
|
|
})
|
|
.or_insert((score, visible_idx));
|
|
}
|
|
|
|
let mut deduped: Vec<(u32, usize)> = best_per_command.into_values().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))
|
|
.collect();
|
|
// Tag each candidate from the data map (canonical key); one shared
|
|
// borrow, dropped before the scoring borrow below.
|
|
{
|
|
let command_tags = self.command_tags.borrow();
|
|
for (row, (canonical, _)) in rows.iter_mut().zip(sort_meta.iter()) {
|
|
row.tag = command_tags.get(canonical.as_str()).cloned();
|
|
}
|
|
}
|
|
// Resolve all recency scores under a single borrow (one keystroke =
|
|
// one borrow, not one per candidate).
|
|
let mru_scores: Vec<u64> = {
|
|
let mut m = self.mru.borrow_mut();
|
|
sort_meta
|
|
.iter()
|
|
.map(|(canonical, _)| m.rank_score(trimmed, canonical))
|
|
.collect()
|
|
};
|
|
deduped.sort_by(|a, b| {
|
|
b.0.cmp(&a.0)
|
|
.then_with(|| mru_scores[b.1].cmp(&mru_scores[a.1]))
|
|
.then_with(|| {
|
|
let a_builtin = sort_meta[a.1].1 == CommandSource::Builtin;
|
|
let b_builtin = sort_meta[b.1].1 == CommandSource::Builtin;
|
|
b_builtin.cmp(&a_builtin)
|
|
})
|
|
.then_with(|| rows[a.1].display.cmp(&rows[b.1].display))
|
|
});
|
|
for row in &mut rows {
|
|
row.indices = self.matcher.indices(row.display.as_str());
|
|
}
|
|
deduped
|
|
.into_iter()
|
|
.map(|(_, idx)| rows[idx].clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Generate argument suggestions for the current input.
|
|
fn arg_suggestions_for_input(
|
|
&mut self,
|
|
text: &str,
|
|
input: &SlashInput,
|
|
models: &ModelState,
|
|
) -> Vec<SuggestionRow> {
|
|
let Some(invocation) = parse_invocation(text) else {
|
|
return Vec::new();
|
|
};
|
|
// Clone the Arc to release the borrow on self.registry before
|
|
// calling arg_suggestions (which needs &mut self for the matcher).
|
|
let Some(command) = self.registry.get(invocation.token).cloned() else {
|
|
return Vec::new();
|
|
};
|
|
// Hidden commands never produce arg suggestions either.
|
|
let offered = {
|
|
let visible_ctx = self.app_ctx(models);
|
|
command_offered(command.as_ref(), &visible_ctx, self.hide_session_scoped)
|
|
};
|
|
if !offered {
|
|
return Vec::new();
|
|
}
|
|
self.arg_suggestions(command.as_ref(), models, &input.args_query)
|
|
}
|
|
|
|
fn argument_highlight_indices(&mut self, query: &str, display: &str) -> Vec<u32> {
|
|
let token = query.split_whitespace().next_back().unwrap_or("");
|
|
let fragment = token.rsplit(['/', '\\']).next().unwrap_or(token);
|
|
self.matcher
|
|
.indices_for(fragment, display)
|
|
.or_else(|| {
|
|
fragment
|
|
.rsplit_once('.')
|
|
.and_then(|(_, suffix)| self.matcher.indices_for(suffix, display))
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Generate argument suggestions for a specific command.
|
|
fn arg_suggestions(
|
|
&mut self,
|
|
command: &dyn SlashCommand,
|
|
models: &ModelState,
|
|
query: &str,
|
|
) -> Vec<SuggestionRow> {
|
|
let ctx = self.app_ctx(models);
|
|
if !command.takes_args_now(&ctx) {
|
|
return Vec::new();
|
|
}
|
|
let Some(items) = command.suggest_args(&ctx, query) else {
|
|
return Vec::new();
|
|
};
|
|
if items.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let trimmed = query.trim();
|
|
if trimmed.is_empty() {
|
|
return items.iter().map(SuggestionRow::from_arg).collect();
|
|
}
|
|
let hits = self
|
|
.matcher
|
|
.rank(items.as_slice(), trimmed, items.len(), |item| {
|
|
item.match_text.as_str()
|
|
});
|
|
hits.into_iter()
|
|
.map(|(idx, _)| {
|
|
let mut row = SuggestionRow::from_arg(&items[idx]);
|
|
row.indices = self.argument_highlight_indices(trimmed, &row.display);
|
|
row
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Whether `command` should be offered for completion **or execution** on
|
|
/// the current surface.
|
|
///
|
|
/// Combines the command's own [`SlashCommand::visible`] gate with the
|
|
/// controller's session-scope policy: when `hide_session_scoped` is set
|
|
/// (session-less surfaces such as the agent dashboard's dispatch input),
|
|
/// commands that act on a single session — `/compact`, `/fork`,
|
|
/// `/rewind`, … — are suppressed because there is no "current session"
|
|
/// for them to operate on.
|
|
///
|
|
/// Commands that opt in via [`SlashCommand::offered_when_session_less`]
|
|
/// (`/model`, `/plan`, `/multiline`) are exempt from this suppression —
|
|
/// they configure the next spawn or the dashboard input surface itself.
|
|
///
|
|
/// Conversely, [`SlashCommand::dashboard_only`] commands (`/cd`) are
|
|
/// offered ONLY when `hide_session_scoped` is set (the dashboard surface)
|
|
/// and suppressed on every session surface.
|
|
///
|
|
/// Callers that execute slash commands on a session-less surface (e.g.
|
|
/// `dispatch_dashboard_dispatch_slash`) must consult this before
|
|
/// `command.run` so typed tokens that were filtered from the dropdown
|
|
/// fall through as ordinary prompt text rather than running invisibly.
|
|
pub(crate) fn command_offered(
|
|
command: &dyn SlashCommand,
|
|
ctx: &AppCtx,
|
|
hide_session_scoped: bool,
|
|
) -> bool {
|
|
command.visible(ctx)
|
|
&& !(hide_session_scoped
|
|
&& command.session_scoped()
|
|
&& !command.offered_when_session_less())
|
|
// Dashboard-only commands (`/cd`) are the inverse of session-scoped:
|
|
// they only make sense on the session-less dashboard surface (where
|
|
// `hide_session_scoped` is set), so suppress them everywhere else —
|
|
// offered only when the command isn't dashboard-only or we're on the
|
|
// dashboard.
|
|
&& (!command.dashboard_only() || hide_session_scoped)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input analysis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parsed input structure for slash completion.
|
|
struct SlashInput {
|
|
command_range: Range<usize>,
|
|
query: String,
|
|
cursor_in_command: bool,
|
|
args_range: Option<Range<usize>>,
|
|
args_query: String,
|
|
}
|
|
|
|
/// Analyze prompt text for slash command structure.
|
|
///
|
|
/// Returns `None` if the text doesn't start with `/` or is empty.
|
|
fn analyze_input(text: &str, cursor: usize) -> Option<SlashInput> {
|
|
if text.is_empty() || !text.starts_with('/') {
|
|
return None;
|
|
}
|
|
|
|
let cursor = cursor.min(text.len());
|
|
if text[1..].chars().all(|ch| ch.is_whitespace()) {
|
|
return Some(SlashInput {
|
|
command_range: 0..1,
|
|
query: String::new(),
|
|
cursor_in_command: true,
|
|
args_range: None,
|
|
args_query: String::new(),
|
|
});
|
|
}
|
|
|
|
let mut command_end = text.len();
|
|
for (idx, ch) in text.char_indices() {
|
|
if idx == 0 {
|
|
continue;
|
|
}
|
|
if ch.is_whitespace() {
|
|
command_end = idx;
|
|
break;
|
|
}
|
|
}
|
|
|
|
let query_end = cursor.clamp(1, command_end);
|
|
let query = if query_end <= 1 {
|
|
String::new()
|
|
} else {
|
|
text[1..query_end].to_string()
|
|
};
|
|
|
|
let cursor_in_command = cursor <= command_end;
|
|
|
|
let mut args_range = None;
|
|
let mut args_query = String::new();
|
|
if !cursor_in_command {
|
|
let mut start = command_end;
|
|
while start < text.len() {
|
|
let ch = match text[start..].chars().next() {
|
|
Some(ch) => ch,
|
|
None => break,
|
|
};
|
|
if ch.is_whitespace() {
|
|
start += ch.len_utf8();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
let end = text.len();
|
|
let query_end = cursor.clamp(start, end);
|
|
if query_end > start {
|
|
args_query = text[start..query_end].to_string();
|
|
}
|
|
args_range = Some(start..end);
|
|
}
|
|
|
|
Some(SlashInput {
|
|
command_range: 0..command_end,
|
|
query,
|
|
cursor_in_command,
|
|
args_range,
|
|
args_query,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Invocation parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parsed slash command invocation.
|
|
pub struct SlashInvocation<'a> {
|
|
/// Command token (e.g., "model" for "/model grok-4").
|
|
pub token: &'a str,
|
|
/// Everything after the command token, trimmed on the left.
|
|
pub args: &'a str,
|
|
}
|
|
|
|
/// Parse a line into a slash command invocation.
|
|
///
|
|
/// Returns `None` if the line doesn't start with `/` or has no command token.
|
|
pub fn parse_invocation(line: &str) -> Option<SlashInvocation<'_>> {
|
|
let remainder = line.strip_prefix('/')?;
|
|
if remainder.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut command_end = remainder.len();
|
|
for (idx, ch) in remainder.char_indices() {
|
|
if ch.is_whitespace() {
|
|
command_end = idx;
|
|
break;
|
|
}
|
|
}
|
|
let token = remainder[..command_end].trim();
|
|
if token.is_empty() {
|
|
return None;
|
|
}
|
|
let args = if command_end < remainder.len() {
|
|
remainder[command_end..].trim_start()
|
|
} else {
|
|
""
|
|
};
|
|
Some(SlashInvocation { token, args })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Completeness check
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Check if a slash command line is complete (ready to execute on Enter).
|
|
///
|
|
/// Uses the two-bit model: `takes_args()` + `args_required()`.
|
|
///
|
|
/// | `takes_args` | `args_required` | Enter with no args |
|
|
/// |-------------|----------------|-------------------|
|
|
/// | `false` | `false` | Executes |
|
|
/// | `true` | `false` | Executes |
|
|
/// | `true` | `true` | Blocks |
|
|
///
|
|
/// Unknown commands (not in registry) are treated as complete -- they will
|
|
/// pass through to the shell.
|
|
pub fn is_command_complete(line: &str, registry: &CommandRegistry) -> bool {
|
|
let Some(invocation) = parse_invocation(line) else {
|
|
return false;
|
|
};
|
|
// Dispatch-tier lookup (menu-only hide; see
|
|
// `CommandRegistry::get_for_dispatch`): menu-hidden commands still run
|
|
// on Enter, so their arg contract gates completeness the same way.
|
|
let Some(command) = registry.get_for_dispatch(invocation.token) else {
|
|
// Unknown command -- treat as complete (will PassThrough).
|
|
return true;
|
|
};
|
|
if !command.takes_args() {
|
|
// No args accepted -- always complete.
|
|
return true;
|
|
}
|
|
if !command.args_required() {
|
|
// Args accepted but optional -- always complete.
|
|
return true;
|
|
}
|
|
// Args required -- complete only if non-empty.
|
|
!invocation.args.trim().is_empty()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mid-text inline slash token scanning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A `/token` found anywhere in the input text.
|
|
#[derive(Debug, Clone)]
|
|
pub struct InlineSlashToken {
|
|
/// Byte range of the entire token including `/`.
|
|
pub range: Range<usize>,
|
|
/// The command name part (without `/`).
|
|
pub name: String,
|
|
/// Whether this token's range contains the cursor position.
|
|
pub has_cursor: bool,
|
|
}
|
|
|
|
/// Byte offset of the next `/token` after `current`, or `None` if none.
|
|
fn next_slash_token_start(
|
|
tokens: &[InlineSlashToken],
|
|
current: &InlineSlashToken,
|
|
) -> Option<usize> {
|
|
tokens
|
|
.iter()
|
|
.filter(|t| t.range.start > current.range.start)
|
|
.map(|t| t.range.start)
|
|
.min()
|
|
}
|
|
|
|
/// Inputs for [`SlashController::refresh_mid_text_slash`].
|
|
struct MidTextSlashRefresh<'a> {
|
|
slash: &'a SlashState,
|
|
text: &'a str,
|
|
cursor: usize,
|
|
token: &'a InlineSlashToken,
|
|
phase: MidTextSlashPhase<'a>,
|
|
all_tokens: &'a [InlineSlashToken],
|
|
models: &'a ModelState,
|
|
previous: &'a SlashSnapshot,
|
|
}
|
|
|
|
/// Inputs for [`SlashController::refresh_mid_text_slash_args`].
|
|
struct MidTextSlashArgsRefresh<'a> {
|
|
text: &'a str,
|
|
cursor: usize,
|
|
token: &'a InlineSlashToken,
|
|
all_tokens: &'a [InlineSlashToken],
|
|
models: &'a ModelState,
|
|
is_recognized: bool,
|
|
previous: &'a SlashSnapshot,
|
|
}
|
|
|
|
/// Whether the cursor is on a mid-text `/token` or in its args (before the next token).
|
|
enum MidTextSlashPhase<'a> {
|
|
Command(&'a InlineSlashToken),
|
|
Args(&'a InlineSlashToken),
|
|
}
|
|
|
|
fn mid_text_slash_context<'a>(
|
|
text: &str,
|
|
cursor: usize,
|
|
tokens: &'a [InlineSlashToken],
|
|
) -> Option<MidTextSlashPhase<'a>> {
|
|
if let Some(token) = tokens.iter().find(|t| t.has_cursor) {
|
|
return Some(MidTextSlashPhase::Command(token));
|
|
}
|
|
|
|
for (i, token) in tokens.iter().enumerate() {
|
|
if cursor <= token.range.end {
|
|
continue;
|
|
}
|
|
let region_end = tokens
|
|
.get(i + 1)
|
|
.map(|t| t.range.start)
|
|
.unwrap_or(text.len());
|
|
if cursor > region_end {
|
|
continue;
|
|
}
|
|
let after_cmd = text.get(token.range.end..cursor)?;
|
|
if !after_cmd.starts_with(|c: char| c.is_whitespace()) {
|
|
continue;
|
|
}
|
|
return Some(MidTextSlashPhase::Args(token));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// True when completion should use the mid-text path instead of leading `/` parsing.
|
|
fn should_use_mid_text_refresh(
|
|
phase: &MidTextSlashPhase<'_>,
|
|
leading: Option<&SlashInput>,
|
|
cursor: usize,
|
|
) -> bool {
|
|
match phase {
|
|
MidTextSlashPhase::Command(token) => match leading {
|
|
None => true,
|
|
Some(input) => {
|
|
token.range.start > 0
|
|
|| cursor > input.command_range.end
|
|
|| token.range != input.command_range
|
|
}
|
|
},
|
|
MidTextSlashPhase::Args(token) => match leading {
|
|
None => true,
|
|
Some(input) => token.range != input.command_range || cursor > input.command_range.end,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Scan input for all `/word` tokens at any position.
|
|
///
|
|
/// A slash token is `/` followed by one or more non-whitespace chars, where
|
|
/// the `/` is either at position 0 or preceded by whitespace (avoids matching
|
|
/// file paths like `foo/bar`).
|
|
pub fn scan_inline_slash_tokens(text: &str, cursor: usize) -> Vec<InlineSlashToken> {
|
|
let cursor = cursor.min(text.len());
|
|
let mut tokens = Vec::new();
|
|
let mut iter = text.char_indices().peekable();
|
|
|
|
while let Some((idx, ch)) = iter.next() {
|
|
if ch != '/' {
|
|
continue;
|
|
}
|
|
// `/` must be at start or preceded by whitespace.
|
|
if idx > 0 {
|
|
let prev_byte = text.as_bytes()[idx - 1];
|
|
if !prev_byte.is_ascii_whitespace() {
|
|
continue;
|
|
}
|
|
}
|
|
// Collect non-whitespace chars after `/`.
|
|
let name_start = idx + ch.len_utf8();
|
|
let mut name_end = name_start;
|
|
while let Some(&(next_idx, next_ch)) = iter.peek() {
|
|
if next_ch.is_whitespace() {
|
|
break;
|
|
}
|
|
name_end = next_idx + next_ch.len_utf8();
|
|
iter.next();
|
|
}
|
|
if name_end <= name_start {
|
|
continue; // bare `/` with nothing after
|
|
}
|
|
let name = text[name_start..name_end].to_string();
|
|
let range = idx..name_end;
|
|
let has_cursor = cursor >= range.start && cursor <= range.end;
|
|
tokens.push(InlineSlashToken {
|
|
range,
|
|
name,
|
|
has_cursor,
|
|
});
|
|
}
|
|
tokens
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use agent_client_protocol as acp;
|
|
|
|
use crate::acp::model_state::ModelState;
|
|
|
|
use super::registry::CommandRegistry;
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_invocation_with_args() {
|
|
let inv = parse_invocation("/model grok-code-fast-1").expect("parsed");
|
|
assert_eq!(inv.token, "model");
|
|
assert_eq!(inv.args, "grok-code-fast-1");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_invocation_no_args() {
|
|
let inv = parse_invocation("/exit").expect("parsed");
|
|
assert_eq!(inv.token, "exit");
|
|
assert_eq!(inv.args, "");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_bare_slash() {
|
|
assert!(parse_invocation("/").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_slash() {
|
|
assert!(parse_invocation("model foo").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_string() {
|
|
assert!(parse_invocation("").is_none());
|
|
}
|
|
|
|
// -- is_command_complete tests --
|
|
|
|
fn test_registry() -> CommandRegistry {
|
|
CommandRegistry::new(commands::builtin_commands())
|
|
}
|
|
|
|
#[test]
|
|
fn no_arg_command_is_complete() {
|
|
let reg = test_registry();
|
|
assert!(is_command_complete("/exit", ®));
|
|
assert!(is_command_complete("/quit", ®));
|
|
assert!(is_command_complete("/new", ®));
|
|
assert!(is_command_complete("/clear", ®));
|
|
}
|
|
|
|
#[test]
|
|
fn optional_arg_command_is_complete_without_args() {
|
|
let reg = test_registry();
|
|
// /compact has takes_args=true, args_required=false.
|
|
assert!(is_command_complete("/compact", ®));
|
|
assert!(is_command_complete("/compact some context", ®));
|
|
}
|
|
|
|
#[test]
|
|
fn required_arg_command_blocks_without_args() {
|
|
let reg = test_registry();
|
|
// /model has takes_args=true, args_required=true.
|
|
assert!(!is_command_complete("/model", ®));
|
|
assert!(!is_command_complete("/model ", ®));
|
|
assert!(is_command_complete("/model grok-4", ®));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_command_is_complete() {
|
|
let reg = test_registry();
|
|
// Unknown commands pass through.
|
|
assert!(is_command_complete("/unknown", ®));
|
|
assert!(is_command_complete("/foo bar", ®));
|
|
}
|
|
|
|
#[test]
|
|
fn bare_slash_is_not_complete() {
|
|
let reg = test_registry();
|
|
assert!(!is_command_complete("/", ®));
|
|
}
|
|
|
|
#[test]
|
|
fn non_slash_is_not_complete() {
|
|
let reg = test_registry();
|
|
assert!(!is_command_complete("hello", ®));
|
|
}
|
|
|
|
// -- Controller tests --
|
|
|
|
#[test]
|
|
fn controller_surfaces_commands_without_query() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
assert!(!snapshot.matches.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn gboom_never_appears_in_suggestions() {
|
|
// The /gboom easter egg is executable but must stay out of the
|
|
// dropdown: not in the full list, not via prefix, not via exact name.
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
for query in ["/", "/g", "/gbo", "/gboom"] {
|
|
ctrl.refresh(&state, query, query.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot
|
|
.matches
|
|
.iter()
|
|
.all(|row| !row.display.contains("gboom")),
|
|
"/gboom leaked into suggestions for query {query:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gboom_still_resolves_for_execution() {
|
|
// Dispatch resolves via `registry.get()`, which ignores `visible()`.
|
|
let reg = test_registry();
|
|
let cmd = reg.get("gboom").expect("/gboom resolvable for dispatch");
|
|
assert_eq!(cmd.name(), "gboom");
|
|
}
|
|
|
|
/// `/debug` lists via `visible()` = cfg!(debug_assertions); tests
|
|
/// compile with debug_assertions, so it must surface here. Release
|
|
/// builds flip the same constant to false (the /gboom hidden
|
|
/// mechanism), which is untestable from a debug test build — hence
|
|
/// the cfg gate rather than a release-side assertion.
|
|
#[test]
|
|
#[cfg(debug_assertions)]
|
|
fn debug_appears_in_suggestions_on_debug_binaries() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
for query in ["/", "/deb", "/debug"] {
|
|
ctrl.refresh(&state, query, query.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot
|
|
.matches
|
|
.iter()
|
|
.any(|row| row.display.contains("debug")),
|
|
"/debug missing from suggestions for query {query:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn debug_resolves_for_execution() {
|
|
let reg = test_registry();
|
|
let cmd = reg.get("debug").expect("/debug resolvable for dispatch");
|
|
assert_eq!(cmd.name(), "debug");
|
|
}
|
|
|
|
#[test]
|
|
fn controller_suggests_partial_command() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "/mo";
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
assert!(
|
|
snapshot.matches.iter().any(|row| row.display == "/model"),
|
|
"expected /model in matches"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_silences_double_slash() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "//", 2, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(!snapshot.open);
|
|
assert!(snapshot.matches.is_empty());
|
|
}
|
|
|
|
/// Ctrl-a then `/` in front of existing text must open the menu: the
|
|
/// query is cursor-clamped to "", so the full command list shows exactly
|
|
/// like `/` on an empty composer.
|
|
#[test]
|
|
fn slash_typed_before_existing_text_opens_full_menu() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let full_list_len = state.snapshot().matches.len();
|
|
|
|
ctrl.refresh(&state, "/hello world", 1, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.active);
|
|
assert!(snapshot.open, "menu must open with the cursor on the `/`");
|
|
assert_eq!(snapshot.query, "");
|
|
assert_eq!(
|
|
snapshot.matches.len(),
|
|
full_list_len,
|
|
"cursor-clamped empty query must show the full list"
|
|
);
|
|
}
|
|
|
|
/// Cursor mid-token while args follow: the menu opens filtered by the
|
|
/// cursor-clamped prefix instead of staying closed.
|
|
#[test]
|
|
fn cursor_inside_leading_command_with_args_filters_by_prefix() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
// Cursor 3 in "/mod grok-4" clamps the query to "mo".
|
|
ctrl.refresh(&state, "/mod grok-4", 3, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
assert_eq!(snapshot.query, "mo");
|
|
assert!(
|
|
snapshot.matches.iter().any(|row| row.display == "/model"),
|
|
"expected /model in matches"
|
|
);
|
|
}
|
|
|
|
/// Cursor moved back inside an already-complete recognized command opens
|
|
/// the menu too (same as mid-text tokens today); recognition holds and no
|
|
/// inline ghost is drawn over the existing text.
|
|
#[test]
|
|
fn cursor_inside_recognized_command_with_args_opens_menu() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/model grok-4", 3, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
assert!(snapshot.command_recognized);
|
|
assert!(
|
|
snapshot.matches.iter().any(|row| row.display == "/model"),
|
|
"expected /model in matches"
|
|
);
|
|
assert!(
|
|
snapshot.inline_ghost.is_none(),
|
|
"recognized command must not draw a ghost over existing text"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_close_hides_matches() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/model", 6, &models);
|
|
assert!(state.snapshot().open);
|
|
|
|
state.close();
|
|
let snapshot = state.snapshot();
|
|
assert!(!snapshot.open);
|
|
assert!(snapshot.matches.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn controller_suggests_alias_display_for_alias() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/m", 2, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
let first = snapshot.matches.first().expect("match");
|
|
assert_eq!(first.display, "/m");
|
|
assert!(first.insert_text.starts_with("/m"));
|
|
}
|
|
|
|
/// `/sessions` survives the sessions-modal removal as an alias of
|
|
/// `/dashboard`: typing it must complete with the alias spelling and the
|
|
/// dashboard command's description.
|
|
#[test]
|
|
fn controller_suggests_sessions_alias_for_dashboard() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
// `/dashboard` is feature-flag gated (hidden by default); the alias
|
|
// is only offered once the flag reveals the canonical command.
|
|
ctrl.registry_mut().set_dashboard_visible(true);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/sessions", 9, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
let row = snapshot
|
|
.matches
|
|
.iter()
|
|
.find(|r| r.display == "/sessions")
|
|
.expect("/sessions must be offered in completion");
|
|
assert_eq!(
|
|
row.description,
|
|
crate::slash::commands::dashboard::DashboardCommand.description(),
|
|
"alias must carry the dashboard command's description"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_suggests_model_args() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let mut models = ModelState::default();
|
|
let model_id = acp::ModelId::new(Arc::from("example"));
|
|
models.available.insert(
|
|
model_id.clone(),
|
|
acp::ModelInfo::new(model_id, "Example".to_string()),
|
|
);
|
|
|
|
let text = "/model e";
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
assert!(
|
|
snapshot
|
|
.matches
|
|
.iter()
|
|
.any(|row| row.display.contains("Example"))
|
|
);
|
|
assert!(snapshot.args_range.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn no_placeholder_when_cursor_at_start_of_existing_args() {
|
|
// Simulates the user typing "hello", then prepending "/model ".
|
|
// Cursor ends up right at the start of the args ("hello"), so
|
|
// args_query is empty but the args range is non-empty.
|
|
// The placeholder must NOT appear.
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "/model hello";
|
|
// Cursor at 7: right after "/model ", before 'h'.
|
|
ctrl.refresh(&state, text, 7, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
!snapshot.args_query_is_empty,
|
|
"args_query_is_empty must be false when args range contains text"
|
|
);
|
|
assert!(
|
|
snapshot.args_placeholder.is_none(),
|
|
"no placeholder when existing args text is present"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_selection_wraps_around() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/m", 2, &models);
|
|
let len = state.snapshot().matches.len();
|
|
assert!(len >= 2, "need at least 2 matches");
|
|
|
|
// At first item, pressing Up wraps to last.
|
|
state.update(|s| s.selected = 0);
|
|
ctrl.move_selection(&state, -1);
|
|
assert_eq!(state.snapshot().selected, len - 1);
|
|
|
|
// At last item, pressing Down wraps to first.
|
|
state.update(|s| s.selected = len - 1);
|
|
ctrl.move_selection(&state, 1);
|
|
assert_eq!(state.snapshot().selected, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_scroll_clamps_at_edges() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/m", 2, &models);
|
|
let len = state.snapshot().matches.len();
|
|
assert!(len >= 2, "need at least 2 matches");
|
|
|
|
// Scrolling up at the first item stays at the first item (no wrap).
|
|
state.update(|s| s.selected = 0);
|
|
ctrl.scroll_selection(&state, -1);
|
|
assert_eq!(state.snapshot().selected, 0);
|
|
|
|
// Scrolling down at the last item stays at the last item (no wrap).
|
|
state.update(|s| s.selected = len - 1);
|
|
ctrl.scroll_selection(&state, 1);
|
|
assert_eq!(state.snapshot().selected, len - 1);
|
|
}
|
|
|
|
#[test]
|
|
fn non_slash_text_produces_inactive_snapshot() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "hello world", 5, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(!snapshot.active);
|
|
assert!(!snapshot.open);
|
|
}
|
|
|
|
#[test]
|
|
fn file_path_not_recognized_as_command() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/Users/foo/bar", 14, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.active, "still active because starts with /");
|
|
assert!(!snapshot.open, "dropdown should be closed");
|
|
assert!(
|
|
!snapshot.command_recognized,
|
|
"file path should not be recognized as a command"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognized_command_sets_command_recognized() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/model", 6, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.active);
|
|
assert!(
|
|
snapshot.command_recognized,
|
|
"known command should be recognized"
|
|
);
|
|
}
|
|
|
|
// -- session-scoped surface filtering (agent dashboard) --
|
|
|
|
/// On a session-less surface (the agent dashboard's dispatch input),
|
|
/// commands that act on a single session are suppressed from completion
|
|
/// while pager-global commands remain. See `SlashCommand::session_scoped`.
|
|
#[test]
|
|
fn hide_session_scoped_filters_session_commands_from_dropdown() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.set_hide_session_scoped(true);
|
|
// `/dashboard` is feature-flag gated (hidden by default in the
|
|
// registry); the session-less surface under test is the dashboard's
|
|
// own dispatch input, so the flag is necessarily on there.
|
|
ctrl.registry_mut().set_dashboard_visible(true);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open);
|
|
let names: Vec<&str> = snapshot
|
|
.matches
|
|
.iter()
|
|
.map(|r| r.display.as_str())
|
|
.collect();
|
|
|
|
// Pager-global commands stay, plus session-scoped opt-ins
|
|
// (`offered_when_session_less`): `/model`/`/plan` stage the next
|
|
// spawn; `/multiline` toggles compose on the dashboard inputs.
|
|
for keep in [
|
|
"/quit",
|
|
"/new",
|
|
"/theme",
|
|
"/settings",
|
|
"/dashboard",
|
|
"/resume",
|
|
"/model",
|
|
"/plan",
|
|
"/multiline",
|
|
] {
|
|
assert!(
|
|
names.contains(&keep),
|
|
"{keep} should remain on the dashboard, got {names:?}"
|
|
);
|
|
}
|
|
// Session-scoped commands without a session-less opt-in are gone.
|
|
for hide in [
|
|
"/compact",
|
|
"/fork",
|
|
"/rewind",
|
|
"/share",
|
|
"/context",
|
|
"/copy",
|
|
"/export",
|
|
"/rename",
|
|
"/btw",
|
|
"/session-info",
|
|
"/find",
|
|
"/doctor",
|
|
] {
|
|
assert!(
|
|
!names.contains(&hide),
|
|
"{hide} should be hidden on the dashboard, got {names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The default surface (agent view) keeps showing session-scoped commands.
|
|
#[test]
|
|
fn default_surface_keeps_session_scoped_commands() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let names: Vec<String> = state
|
|
.snapshot()
|
|
.matches
|
|
.iter()
|
|
.map(|r| r.display.clone())
|
|
.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
|
|
/// session-less dashboard surface but is hidden on the default (agent
|
|
/// view) surface — the inverse of session-scoped commands.
|
|
#[test]
|
|
fn dashboard_only_command_hidden_off_dashboard() {
|
|
let models = ModelState::default();
|
|
|
|
// Default surface (agent view): `/cd` is hidden.
|
|
let mut agent = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
agent.refresh(&state, "/cd", 3, &models);
|
|
let agent_names: Vec<String> = state
|
|
.snapshot()
|
|
.matches
|
|
.iter()
|
|
.map(|r| r.display.clone())
|
|
.collect();
|
|
assert!(
|
|
!agent_names.iter().any(|d| d == "/cd"),
|
|
"/cd must be hidden off the dashboard, got {agent_names:?}"
|
|
);
|
|
|
|
// Dashboard surface (session-less): `/cd` is offered.
|
|
let mut dash = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
dash.set_hide_session_scoped(true);
|
|
let state = SlashState::default();
|
|
dash.refresh(&state, "/cd", 3, &models);
|
|
let dash_names: Vec<String> = state
|
|
.snapshot()
|
|
.matches
|
|
.iter()
|
|
.map(|r| r.display.clone())
|
|
.collect();
|
|
assert!(
|
|
dash_names.iter().any(|d| d == "/cd"),
|
|
"/cd must be offered on the dashboard, got {dash_names:?}"
|
|
);
|
|
}
|
|
|
|
/// Fuzzy queries also exclude session-scoped commands while keeping
|
|
/// global ones that match the same prefix (`/compact` is hidden,
|
|
/// `/compact-mode` stays).
|
|
#[test]
|
|
fn hide_session_scoped_filters_fuzzy_query() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.set_hide_session_scoped(true);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/co", 3, &models);
|
|
let names: Vec<String> = state
|
|
.snapshot()
|
|
.matches
|
|
.iter()
|
|
.map(|r| r.display.clone())
|
|
.collect();
|
|
assert!(!names.iter().any(|d| d == "/compact"));
|
|
assert!(!names.iter().any(|d| d == "/context"));
|
|
assert!(!names.iter().any(|d| d == "/copy"));
|
|
// The pager-global /compact-mode also fuzzy-matches "co" and stays.
|
|
assert!(
|
|
names.iter().any(|d| d == "/compact-mode"),
|
|
"global commands matching the query must remain, got {names:?}"
|
|
);
|
|
}
|
|
|
|
/// A fully-typed session command is neither recognized (no teal /
|
|
/// placeholder) nor offered arg suggestions on the dashboard surface.
|
|
#[test]
|
|
fn hidden_session_command_not_recognized_and_no_args() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.set_hide_session_scoped(true);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/compact", 8, &models);
|
|
assert!(
|
|
!state.snapshot().command_recognized,
|
|
"/compact must not be recognized on the session-less dashboard"
|
|
);
|
|
|
|
let text = "/compact ";
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
assert!(
|
|
state.snapshot().matches.is_empty(),
|
|
"a hidden command must not produce arg suggestions"
|
|
);
|
|
}
|
|
|
|
/// `/model`, `/plan`, and `/multiline` opt in via `offered_when_session_less`,
|
|
/// so they stay recognized on the dashboard even though they're session-scoped.
|
|
#[test]
|
|
fn session_less_opt_in_commands_recognized_on_dashboard() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.set_hide_session_scoped(true);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/plan", 5, &models);
|
|
assert!(
|
|
state.snapshot().command_recognized,
|
|
"/plan must be recognized on the dashboard"
|
|
);
|
|
|
|
ctrl.refresh(&state, "/model", 6, &models);
|
|
assert!(
|
|
state.snapshot().command_recognized,
|
|
"/model must be recognized on the dashboard"
|
|
);
|
|
|
|
ctrl.refresh(&state, "/multiline", 10, &models);
|
|
assert!(
|
|
state.snapshot().command_recognized,
|
|
"/multiline must be recognized on the dashboard"
|
|
);
|
|
}
|
|
|
|
// -- scan_inline_slash_tokens tests --
|
|
|
|
#[test]
|
|
fn scan_finds_mid_text_slash_token() {
|
|
let tokens = scan_inline_slash_tokens("do /model now", 6);
|
|
assert_eq!(tokens.len(), 1);
|
|
assert_eq!(tokens[0].name, "model");
|
|
assert_eq!(tokens[0].range, 3..9);
|
|
assert!(tokens[0].has_cursor);
|
|
}
|
|
|
|
#[test]
|
|
fn scan_finds_multiple_tokens() {
|
|
let tokens = scan_inline_slash_tokens("run /commit and /review", 4);
|
|
assert_eq!(tokens.len(), 2);
|
|
assert_eq!(tokens[0].name, "commit");
|
|
assert_eq!(tokens[1].name, "review");
|
|
assert!(tokens[0].has_cursor);
|
|
assert!(!tokens[1].has_cursor);
|
|
}
|
|
|
|
#[test]
|
|
fn scan_ignores_file_paths() {
|
|
let tokens = scan_inline_slash_tokens("edit foo/bar/baz.rs", 10);
|
|
assert!(tokens.is_empty(), "slashes inside words are not tokens");
|
|
}
|
|
|
|
#[test]
|
|
fn scan_handles_start_of_line() {
|
|
let tokens = scan_inline_slash_tokens("/exit now", 3);
|
|
assert_eq!(tokens.len(), 1);
|
|
assert_eq!(tokens[0].name, "exit");
|
|
assert!(tokens[0].has_cursor);
|
|
}
|
|
|
|
#[test]
|
|
fn scan_bare_slash_ignored() {
|
|
let tokens = scan_inline_slash_tokens("do / something", 4);
|
|
assert!(tokens.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn scan_cursor_at_token_end() {
|
|
let tokens = scan_inline_slash_tokens("run /model", 10);
|
|
assert_eq!(tokens.len(), 1);
|
|
assert!(tokens[0].has_cursor, "cursor at end of token should match");
|
|
}
|
|
|
|
// -- Inline ghost text tests --
|
|
|
|
#[test]
|
|
fn inline_ghost_for_partial_command() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "do /mod", 7, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.active, "mid-text slash under cursor is active");
|
|
if let Some(ref ghost) = snapshot.inline_ghost {
|
|
assert_eq!(ghost.full_name, "model");
|
|
assert_eq!(ghost.text, "el");
|
|
assert_eq!(ghost.token_range, 3..7);
|
|
} else {
|
|
panic!("expected inline ghost for partial /mod");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn leading_slash_ghost_tracks_dropdown_selection() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/p", 2, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open, "partial /p should open the dropdown");
|
|
let selected = snapshot
|
|
.selection()
|
|
.expect("dropdown should have a selection");
|
|
let selected_name = selected
|
|
.display
|
|
.strip_prefix('/')
|
|
.unwrap_or(&selected.display);
|
|
let ghost = snapshot
|
|
.inline_ghost
|
|
.as_ref()
|
|
.expect("ghost should mirror the selected dropdown row");
|
|
assert_eq!(
|
|
ghost.full_name, selected_name,
|
|
"ghost must complete the selected row (Tab target), not a separate fuzzy winner"
|
|
);
|
|
assert!(
|
|
selected_name.starts_with('p'),
|
|
"selected row for query 'p' should start with p, got {selected_name}"
|
|
);
|
|
assert_eq!(ghost.text, &selected_name[1..]);
|
|
}
|
|
|
|
#[test]
|
|
fn smart_case_prefix_match_aligns_ghost_with_fuzzy_ranker() {
|
|
// Nucleo Smart case: all-lowercase query is case-insensitive.
|
|
assert!(command_prefix_matches_smart("Privacy", "p"));
|
|
assert!(command_prefix_matches_smart("Privacy", "pr"));
|
|
// Any uppercase in query requires exact (case-sensitive) prefix.
|
|
assert!(command_prefix_matches_smart("Privacy", "P")); // "P" is exact prefix of "Privacy"
|
|
assert!(!command_prefix_matches_smart("privacy", "Pr"));
|
|
assert!(!command_prefix_matches_smart("Privacy", "PR"));
|
|
|
|
let row = SuggestionRow {
|
|
display: "/Privacy".to_string(),
|
|
description: String::new(),
|
|
insert_text: "/Privacy ".to_string(),
|
|
indices: Vec::new(),
|
|
tag: None,
|
|
};
|
|
// Without smart-case, starts_with("p") fails on "Privacy" and ghost disappears
|
|
// while the dropdown still highlights the row via CaseMatching::Smart.
|
|
let ghost = inline_ghost_from_selected_command("p", 1..2, &row).expect(
|
|
"lowercase query must ghost-complete a title-case command (dropdown can select it)",
|
|
);
|
|
assert_eq!(ghost.full_name, "Privacy");
|
|
assert_eq!(ghost.text, "rivacy");
|
|
|
|
// Mixed-case query that is not an exact prefix must not ghost.
|
|
assert!(inline_ghost_from_selected_command("PR", 1..3, &row).is_none());
|
|
}
|
|
|
|
/// Minimal command used to build a hermetic registry where several names
|
|
/// tie at the same fuzzy score, so MRU recency (not the live builtin set)
|
|
/// decides ordering.
|
|
struct TieCmd(&'static str);
|
|
impl SlashCommand for TieCmd {
|
|
fn name(&self) -> &str {
|
|
self.0
|
|
}
|
|
fn description(&self) -> &str {
|
|
""
|
|
}
|
|
fn usage(&self) -> &str {
|
|
""
|
|
}
|
|
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
|
CommandResult::Handled
|
|
}
|
|
}
|
|
|
|
fn tie_controller(names: &[&'static str], seeds: &[(&str, u64)]) -> SlashController {
|
|
let commands: Vec<Arc<dyn SlashCommand>> = names
|
|
.iter()
|
|
.map(|n| Arc::new(TieCmd(n)) as Arc<dyn SlashCommand>)
|
|
.collect();
|
|
let mut store = mru::SlashMru::new_in_memory();
|
|
for (cmd, ts) in seeds {
|
|
store.seed_for_test("", cmd, *ts);
|
|
}
|
|
let mut ctrl = SlashController::new(
|
|
CommandRegistry::new(commands),
|
|
std::path::PathBuf::from("."),
|
|
);
|
|
ctrl.set_mru(std::rc::Rc::new(std::cell::RefCell::new(store)));
|
|
ctrl
|
|
}
|
|
|
|
#[test]
|
|
fn mru_beats_tiebreak_on_equal_fuzzy_score() {
|
|
// Hermetic: three names tie on fuzzy score for `/p`; MRU recency must
|
|
// pick the winner regardless of the live builtin registry.
|
|
let mut ctrl = tie_controller(
|
|
&["privacy", "personas", "plan"],
|
|
&[
|
|
("privacy", 1_700_000_900),
|
|
("personas", 1_700_000_010),
|
|
("plan", 1_700_000_050),
|
|
],
|
|
);
|
|
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.refresh(&state, "/p", 2, &models);
|
|
let snapshot = state.snapshot();
|
|
let top = snapshot.selection().expect("dropdown open").display.clone();
|
|
assert_eq!(
|
|
top, "/privacy",
|
|
"MRU should rank the most-recently-used tie winner first, got {top}"
|
|
);
|
|
let ghost = snapshot
|
|
.inline_ghost
|
|
.as_ref()
|
|
.expect("ghost tracks selected row");
|
|
assert_eq!(ghost.full_name, "privacy");
|
|
}
|
|
|
|
/// Tier-restricted commands stay in the dropdown (discoverability) even
|
|
/// though `get()` blocks execution — invoking one shows the SuperGrok
|
|
/// upsell (covered by the dispatch-level tests).
|
|
#[test]
|
|
fn restricted_commands_stay_visible_in_dropdown() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.registry_mut()
|
|
.set_restricted_commands(&["usage".to_string()]);
|
|
assert!(
|
|
ctrl.registry().get("usage").is_none(),
|
|
"execution stays blocked"
|
|
);
|
|
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.refresh(&state, "/usa", 4, &models);
|
|
let snapshot = state.snapshot();
|
|
let top = snapshot.selection().expect("dropdown open").display.clone();
|
|
assert_eq!(
|
|
top, "/usage",
|
|
"restricted command stays discoverable in the dropdown"
|
|
);
|
|
}
|
|
|
|
/// Gate open → both `/always-approve` and `/auto` offered + dispatchable.
|
|
/// Gate closed → `/auto` hard-hidden; `/always-approve` still offered.
|
|
#[test]
|
|
fn set_auto_mode_available_gates_only_auto() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let visible = |ctrl: &SlashController, name: &str| ctrl.registry().get(name).is_some();
|
|
let dispatchable =
|
|
|ctrl: &SlashController, name: &str| ctrl.registry().get_for_dispatch(name).is_some();
|
|
|
|
ctrl.set_auto_mode_available(true);
|
|
assert!(visible(&ctrl, "always-approve"));
|
|
assert!(visible(&ctrl, "auto"));
|
|
assert!(dispatchable(&ctrl, "always-approve"));
|
|
assert!(dispatchable(&ctrl, "auto"));
|
|
|
|
ctrl.set_auto_mode_available(false);
|
|
assert!(visible(&ctrl, "always-approve"));
|
|
assert!(!visible(&ctrl, "auto"));
|
|
assert!(dispatchable(&ctrl, "always-approve"));
|
|
assert!(!dispatchable(&ctrl, "auto"));
|
|
}
|
|
|
|
/// With the gate open, both permission-mode toggles appear in completion
|
|
/// for full-list, prefix, and exact-name queries.
|
|
#[test]
|
|
fn permission_mode_toggles_appear_in_completion_when_available() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.set_auto_mode_available(true);
|
|
|
|
for (query, display) in [
|
|
("/", "/always-approve"),
|
|
("/alw", "/always-approve"),
|
|
("/always-approve", "/always-approve"),
|
|
("/", "/auto"),
|
|
("/au", "/auto"),
|
|
("/auto", "/auto"),
|
|
] {
|
|
ctrl.refresh(&state, query, query.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot.matches.iter().any(|row| row.display == display),
|
|
"{display} missing from completion for query {query:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// No-arg toggles are complete so Enter submits immediately.
|
|
#[test]
|
|
fn permission_mode_toggles_are_complete_for_enter() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.set_auto_mode_available(true);
|
|
assert!(is_command_complete("/always-approve", ctrl.registry()));
|
|
assert!(is_command_complete("/auto", ctrl.registry()));
|
|
}
|
|
|
|
#[test]
|
|
fn move_selection_updates_inline_ghost() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.refresh(&state, "/p", 2, &models);
|
|
let before = state.snapshot();
|
|
assert!(before.matches.len() >= 2, "need multiple /p hits");
|
|
let first_name = before
|
|
.selection()
|
|
.expect("selection")
|
|
.command_name()
|
|
.to_string();
|
|
|
|
ctrl.move_selection(&state, 1);
|
|
let after = state.snapshot();
|
|
let second_name = after
|
|
.selection()
|
|
.expect("selection after move")
|
|
.command_name()
|
|
.to_string();
|
|
assert_ne!(first_name, second_name, "selection should change");
|
|
let ghost = after
|
|
.inline_ghost
|
|
.as_ref()
|
|
.expect("ghost must refresh with selection");
|
|
assert_eq!(
|
|
ghost.full_name, second_name,
|
|
"arrow nav must keep ghost on the highlighted row"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ghost_tracks_selection_when_skill_wins_mru_tie() {
|
|
// Repro of the reported "/p shows ghost `pager-headless` but Tab inserts
|
|
// `personas`" divergence: a builtin (`personas`) and an ACP skill
|
|
// (`pager-headless`) tie on fuzzy score for `/p`, MRU favors the skill.
|
|
// The ghost must equal the selected (Tab-accepted) row in every case.
|
|
let mut ctrl = SlashController::new(
|
|
CommandRegistry::new(vec![Arc::new(TieCmd("personas"))]),
|
|
std::path::PathBuf::from("."),
|
|
);
|
|
ctrl.registry_mut()
|
|
.set_acp_commands(&[agent_client_protocol::AvailableCommand::new(
|
|
"pager-headless".to_string(),
|
|
String::new(),
|
|
)]);
|
|
let mut store = mru::SlashMru::new_in_memory();
|
|
store.seed_for_test("", "pager-headless", 1_700_000_900);
|
|
ctrl.set_mru(std::rc::Rc::new(std::cell::RefCell::new(store)));
|
|
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.refresh(&state, "/p", 2, &models);
|
|
let snap = state.snapshot();
|
|
|
|
let selected = snap
|
|
.selection()
|
|
.expect("dropdown open")
|
|
.command_name()
|
|
.to_string();
|
|
let ghost = snap.inline_ghost.as_ref().expect("ghost present for /p");
|
|
assert_eq!(
|
|
ghost.full_name, selected,
|
|
"ghost must complete the selected row, not a separate ranker's pick"
|
|
);
|
|
assert_eq!(
|
|
selected, "pager-headless",
|
|
"MRU should make the recently-used skill win the /p tie"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn record_command_use_stores_canonical_for_alias() {
|
|
// Default controller store is already isolated + in-memory.
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.record_command_use("e", "exit");
|
|
ctrl.record_command_use("q", "/quit");
|
|
assert!(ctrl.mru_last_used("", "quit") > 0);
|
|
assert_eq!(ctrl.mru_last_used("", "exit"), 0);
|
|
}
|
|
|
|
/// Inject a per-command tag map into a controller (test seam).
|
|
fn set_tags(ctrl: &mut SlashController, entries: &[(&str, &str)]) {
|
|
let map: std::collections::HashMap<String, String> = entries
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect();
|
|
ctrl.set_command_tags(std::rc::Rc::new(std::cell::RefCell::new(map)));
|
|
}
|
|
|
|
/// Tag of the row whose display equals `name` in the current snapshot.
|
|
fn row_tag(snap: &SlashSnapshot, name: &str) -> Option<String> {
|
|
snap.matches
|
|
.iter()
|
|
.find(|r| r.display == name)
|
|
.unwrap_or_else(|| panic!("row {name} missing"))
|
|
.tag
|
|
.clone()
|
|
}
|
|
|
|
/// A command with a tag-map entry (keyed by canonical) carries that tag in
|
|
/// both the empty-query and the typed-query branches; one without has `None`.
|
|
#[test]
|
|
fn command_row_tag_from_map_keyed_by_canonical() {
|
|
let mut ctrl = tie_controller(&["alpha", "bravo"], &[]);
|
|
set_tags(&mut ctrl, &[("alpha", "new")]);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let snap = state.snapshot();
|
|
assert_eq!(row_tag(&snap, "/alpha"), Some("new".to_string()));
|
|
assert_eq!(row_tag(&snap, "/bravo"), None);
|
|
|
|
// Typed-query branch tags the same way.
|
|
ctrl.refresh(&state, "/al", 3, &models);
|
|
let typed = state.snapshot();
|
|
assert_eq!(
|
|
row_tag(&typed, "/alpha"),
|
|
Some("new".to_string()),
|
|
"typed-query rows carry tags too"
|
|
);
|
|
}
|
|
|
|
/// The bare "/" picker surfaces tagged commands first, preserving registry
|
|
/// order within the tagged and untagged groups (stable; not alphabetized).
|
|
#[test]
|
|
fn empty_query_sorts_tagged_commands_first_stably() {
|
|
// Registry order: alpha, bravo, charlie, delta. Tag the 2nd and 4th.
|
|
let mut ctrl = tie_controller(&["alpha", "bravo", "charlie", "delta"], &[]);
|
|
set_tags(&mut ctrl, &[("bravo", "new"), ("delta", "beta")]);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let order: Vec<String> = state
|
|
.snapshot()
|
|
.matches
|
|
.iter()
|
|
.map(|r| r.display.clone())
|
|
.collect();
|
|
assert_eq!(
|
|
order,
|
|
vec!["/bravo", "/delta", "/alpha", "/charlie"],
|
|
"tagged-first, stable registry order within each group"
|
|
);
|
|
}
|
|
|
|
/// ACP commands — including bundled skills that arrive as skill-shaped ACP
|
|
/// commands — tag from the map the same way as builtins.
|
|
#[test]
|
|
fn acp_and_skill_commands_tag_from_map() {
|
|
let mut ctrl = SlashController::new(
|
|
CommandRegistry::new(vec![
|
|
Arc::new(TieCmd("builtin-cmd")) as Arc<dyn SlashCommand>
|
|
]),
|
|
std::path::PathBuf::from("."),
|
|
);
|
|
// A skill arrives as an ACP command carrying skill meta (scope + path).
|
|
let skill_meta = serde_json::json!({
|
|
"scope": "local",
|
|
"path": "/home/user/.grok/skills/skill-cmd/SKILL.md",
|
|
})
|
|
.as_object()
|
|
.cloned()
|
|
.expect("skill meta is an object");
|
|
let skill_cmd =
|
|
agent_client_protocol::AvailableCommand::new("skill-cmd".to_string(), String::new())
|
|
.meta(skill_meta);
|
|
ctrl.registry_mut().set_acp_commands(&[
|
|
agent_client_protocol::AvailableCommand::new("acp-command".to_string(), String::new()),
|
|
skill_cmd,
|
|
]);
|
|
assert!(
|
|
ctrl.registry()
|
|
.get("skill-cmd")
|
|
.expect("skill command present")
|
|
.is_skill(),
|
|
"skill-shaped ACP command must classify as a skill"
|
|
);
|
|
|
|
set_tags(&mut ctrl, &[("acp-command", "beta"), ("skill-cmd", "new")]);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.refresh(&state, "/", 1, &models);
|
|
let snap = state.snapshot();
|
|
assert_eq!(row_tag(&snap, "/acp-command"), Some("beta".to_string()));
|
|
assert_eq!(row_tag(&snap, "/skill-cmd"), Some("new".to_string()));
|
|
assert_eq!(row_tag(&snap, "/builtin-cmd"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn flat_mru_boosts_recent_command_regardless_of_typed_prefix() {
|
|
// Flat schema (hermetic): using `plan` recently boosts it even when
|
|
// typing `/p`, independent of the live builtin registry.
|
|
let mut ctrl = tie_controller(
|
|
&["plan", "personas"],
|
|
&[("plan", 1_700_000_999), ("personas", 1_700_000_010)],
|
|
);
|
|
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
ctrl.refresh(&state, "/p", 2, &models);
|
|
let snap = state.snapshot();
|
|
let top = snap.selection().expect("dropdown").display.clone();
|
|
assert_eq!(
|
|
top.as_str(),
|
|
"/plan",
|
|
"flat MRU should surface recent /plan on /p ties, got {top}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn submit_records_canonical_for_ranking() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.record_command_use("pager-headless", "pager-headless");
|
|
assert!(ctrl.mru_last_used("", "pager-headless") > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn no_ghost_for_fully_recognized_command() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "do /model now", 9, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot.inline_ghost.is_none(),
|
|
"fully recognized command should not show ghost"
|
|
);
|
|
assert_eq!(snapshot.recognized_tokens.len(), 1);
|
|
assert_eq!(snapshot.recognized_tokens[0], 3..9);
|
|
}
|
|
|
|
#[test]
|
|
fn no_ghost_for_unmatched_prefix() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "do /zzzzz", 9, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot.inline_ghost.is_none(),
|
|
"no match should produce no ghost"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inline_ghost_multiline() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
// "/mod" is on line 2; ghost should still be produced with correct byte range.
|
|
ctrl.refresh(&state, "hello\n/mod", 10, &models);
|
|
let snapshot = state.snapshot();
|
|
let ghost = snapshot
|
|
.inline_ghost
|
|
.as_ref()
|
|
.expect("expected ghost for /mod on line 2");
|
|
assert_eq!(ghost.full_name, "model");
|
|
assert_eq!(ghost.text, "el");
|
|
assert_eq!(ghost.token_range, 6..10);
|
|
}
|
|
|
|
#[test]
|
|
fn recognized_tokens_highlighted() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
ctrl.refresh(&state, "run /exit and /model please", 0, &models);
|
|
let snapshot = state.snapshot();
|
|
assert_eq!(snapshot.recognized_tokens.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn recognized_token_ranges_matches_composer_refresh() {
|
|
// Parity pin: the submit-time helper must produce exactly the ranges
|
|
// the composer highlighted while typing (same registry + gates).
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "run /exit and /model please but not /zzzzz nor foo/bar";
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let composer = state.snapshot().recognized_tokens;
|
|
|
|
let helper = ctrl.recognized_token_ranges(text, &models);
|
|
assert_eq!(helper, composer);
|
|
assert_eq!(helper, vec![4..9, 14..20]);
|
|
}
|
|
|
|
#[test]
|
|
fn recognized_token_ranges_parity_in_mid_text_state_with_session_scope_hidden() {
|
|
// Dashboard-style surface (session-scoped commands suppressed), cursor
|
|
// in a mid-text token's args: the mid-text refresh path must apply the
|
|
// same membership rule as the helper — /compact (session-scoped) is
|
|
// excluded on this surface, /theme (pager-global) is highlighted.
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
ctrl.set_hide_session_scoped(true);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "do /compact then /theme now";
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let composer = state.snapshot().recognized_tokens;
|
|
|
|
let helper = ctrl.recognized_token_ranges(text, &models);
|
|
assert_eq!(helper, composer);
|
|
assert_eq!(helper, vec![17..23]);
|
|
|
|
// Cursor inside the suppressed /compact token: the under-cursor teal
|
|
// source (command_recognized) must agree with the ranges — no teal
|
|
// flicker while the cursor sits in a not-offered command.
|
|
ctrl.refresh(&state, text, 11, &models);
|
|
let snap = state.snapshot();
|
|
assert!(
|
|
!snap.command_recognized,
|
|
"/compact must not be recognized mid-text on the session-less surface"
|
|
);
|
|
assert_eq!(snap.recognized_tokens, helper);
|
|
}
|
|
|
|
#[test]
|
|
fn recognized_token_ranges_excludes_unknown_and_paths() {
|
|
let ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let models = ModelState::default();
|
|
|
|
assert!(
|
|
ctrl.recognized_token_ranges("do /frobnicate now", &models)
|
|
.is_empty(),
|
|
"unknown /word must not be recognized"
|
|
);
|
|
assert!(
|
|
ctrl.recognized_token_ranges("edit foo/bar/baz.rs", &models)
|
|
.is_empty(),
|
|
"path-like tokens must not be recognized"
|
|
);
|
|
assert!(
|
|
ctrl.recognized_token_ranges("no tokens here", &models)
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mid_text_slash_on_second_line_uses_token_range() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "asdasd /implement agine\n /im";
|
|
let cursor = text.len();
|
|
ctrl.refresh(&state, text, cursor, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot.active,
|
|
"cursor on /im should activate slash completion"
|
|
);
|
|
assert_eq!(
|
|
snapshot.command_range,
|
|
Some(29..32),
|
|
"command_range must be the /im token on line 2, not the leading span"
|
|
);
|
|
assert!(
|
|
snapshot.open || snapshot.inline_ghost.is_some(),
|
|
"partial /im should show dropdown or inline ghost"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn leading_slash_on_second_line_after_first_command() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "/model\n /im";
|
|
let cursor = text.len();
|
|
ctrl.refresh(&state, text, cursor, &models);
|
|
let snapshot = state.snapshot();
|
|
assert_eq!(snapshot.command_range, Some(9..12));
|
|
assert!(snapshot.cursor_in_command);
|
|
}
|
|
|
|
#[test]
|
|
fn mid_text_slash_args_after_token() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "hi /model ";
|
|
let cursor = text.len();
|
|
ctrl.refresh(&state, text, cursor, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
!snapshot.cursor_in_command,
|
|
"cursor in args after /model should leave command mode"
|
|
);
|
|
assert!(snapshot.args_range.is_some());
|
|
assert!(
|
|
snapshot.open || snapshot.args_placeholder.is_some(),
|
|
"args phase should show suggestions or placeholder for /model"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mid_text_cursor_in_first_slash_stays_in_command_mode() {
|
|
let mut ctrl = SlashController::with_builtins(std::path::PathBuf::from("."));
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
let text = "hi /imagine\n\n /execute-plan";
|
|
let cursor = text.find("/imagine").unwrap() + "/imagine".len();
|
|
ctrl.refresh(&state, text, cursor, &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(
|
|
snapshot.cursor_in_command,
|
|
"cursor at end of /imagine must stay in command mode for Tab"
|
|
);
|
|
assert_eq!(snapshot.args_range, None);
|
|
assert_eq!(
|
|
snapshot.command_range,
|
|
Some(3..11),
|
|
"Tab must target /imagine, not the later /execute-plan token"
|
|
);
|
|
}
|
|
|
|
/// Fake command: empty query yields a chained "first" row (trailing
|
|
/// space) and a terminal "second" row; "first " yields terminal rows.
|
|
struct ChainCmd;
|
|
|
|
impl SlashCommand for ChainCmd {
|
|
fn name(&self) -> &str {
|
|
"chain"
|
|
}
|
|
fn description(&self) -> &str {
|
|
"test chain"
|
|
}
|
|
fn usage(&self) -> &str {
|
|
"/chain <a> <b>"
|
|
}
|
|
fn takes_args(&self) -> bool {
|
|
true
|
|
}
|
|
fn suggest_args(&self, _ctx: &AppCtx, args_query: &str) -> Option<Vec<ArgItem>> {
|
|
let item = |display: &str, match_text: &str, insert: &str| ArgItem {
|
|
display: display.into(),
|
|
match_text: match_text.into(),
|
|
insert_text: insert.into(),
|
|
description: String::new(),
|
|
};
|
|
if let Some(rest) = args_query.strip_prefix("first")
|
|
&& rest.starts_with(char::is_whitespace)
|
|
{
|
|
return Some(vec![
|
|
item("alpha", "first alpha", "first alpha"),
|
|
item("beta", "first beta", "first beta"),
|
|
]);
|
|
}
|
|
Some(vec![
|
|
item("first", "first", "first "),
|
|
item("second", "second", "second"),
|
|
])
|
|
}
|
|
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
|
CommandResult::Handled
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn suggest_args_chains_or_terminates_based_on_typed_query() {
|
|
let mut ctrl = SlashController::new(
|
|
CommandRegistry::new(vec![Arc::new(ChainCmd)]),
|
|
std::path::PathBuf::from("."),
|
|
);
|
|
let state = SlashState::default();
|
|
let models = ModelState::default();
|
|
|
|
// Empty query: "first" chains (trailing space), "second" is terminal.
|
|
ctrl.refresh(&state, "/chain ", 7, &models);
|
|
let snap = state.snapshot();
|
|
let rows: Vec<(&str, bool)> = snap
|
|
.matches
|
|
.iter()
|
|
.map(|r| (r.display.as_str(), r.insert_text.ends_with(' ')))
|
|
.collect();
|
|
assert_eq!(rows, vec![("first", true), ("second", false)]);
|
|
|
|
ctrl.refresh(&state, "/chain fir", 10, &models);
|
|
let snap = state.snapshot();
|
|
assert!(snap.open);
|
|
assert_eq!(snap.matches[0].indices, vec![0, 1, 2]);
|
|
|
|
// Typing "first " triggers the phase-2 sub-menu of terminal rows.
|
|
ctrl.refresh(&state, "/chain first ", 13, &models);
|
|
let snap = state.snapshot();
|
|
let rows: Vec<(&str, bool)> = snap
|
|
.matches
|
|
.iter()
|
|
.map(|r| (r.display.as_str(), r.insert_text.ends_with(' ')))
|
|
.collect();
|
|
assert_eq!(rows, vec![("alpha", false), ("beta", false)]);
|
|
|
|
ctrl.refresh(&state, "/chain first al", 15, &models);
|
|
let snap = state.snapshot();
|
|
assert!(snap.open);
|
|
assert_eq!(snap.matches[0].indices, vec![0, 1]);
|
|
}
|
|
|
|
#[test]
|
|
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 = "/doctor";
|
|
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(&"/doctor"), "matches: {displays:?}");
|
|
assert!(!displays.contains(&"/terminal-setup"));
|
|
|
|
for text in ["/doctor ", "/terminal-setup "] {
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(!snapshot.open, "bare args opened for {text:?}");
|
|
assert!(snapshot.matches.is_empty(), "matches for {text:?}");
|
|
}
|
|
for (text, inserted, indices) in [
|
|
("/doctor f", "fix", vec![0]),
|
|
("/doctor fix s", "fix ssh-wrap", vec![0]),
|
|
("/doctor fix ssh", "fix ssh-wrap", vec![0, 1, 2]),
|
|
("/doctor fix terminal.s", "fix ssh-wrap", vec![0]),
|
|
(
|
|
"/doctor fix tmux-c",
|
|
"fix tmux-clipboard",
|
|
vec![0, 1, 2, 3, 4, 5],
|
|
),
|
|
("/doctor fix dcs", "fix dcs-passthrough", vec![0, 1, 2]),
|
|
(
|
|
"/doctor fix tmux-e",
|
|
"fix tmux-extended-keys",
|
|
vec![0, 1, 2, 3, 4, 5],
|
|
),
|
|
("/terminal-setup f", "fix", vec![0]),
|
|
("/terminal-setup fix s", "fix ssh-wrap", vec![0]),
|
|
] {
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(snapshot.open, "no matches for {text:?}");
|
|
assert_eq!(snapshot.matches[0].insert_text, inserted);
|
|
assert_eq!(snapshot.matches[0].indices, indices, "{text:?}");
|
|
}
|
|
|
|
for text in [
|
|
"/doctor fix ssh-wrap",
|
|
"/doctor fix terminal.ssh-wrap",
|
|
"/doctor fix tmux-clipboard",
|
|
"/doctor fix terminal.tmux-clipboard",
|
|
"/doctor fix dcs-passthrough",
|
|
"/doctor fix terminal.dcs-passthrough",
|
|
"/doctor fix tmux-extended-keys",
|
|
"/doctor fix terminal.tmux-extended-keys",
|
|
"/terminal-setup fix ssh-wrap",
|
|
"/terminal-setup fix terminal.ssh-wrap",
|
|
] {
|
|
ctrl.refresh(&state, text, text.len(), &models);
|
|
let snapshot = state.snapshot();
|
|
assert!(!snapshot.open, "exact form left picker open for {text:?}");
|
|
assert!(snapshot.matches.is_empty(), "matches for {text:?}");
|
|
}
|
|
|
|
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"),
|
|
"matches: {displays:?}"
|
|
);
|
|
}
|
|
}
|