From e89da02d494459a25d2ac503fb15cfd32404f789 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 25 Oct 2025 10:30:47 +0200 Subject: [PATCH] feat(commands): add metadata-driven palette with tag filters --- crates/owlen-tui/src/commands/mod.rs | 1167 ++++++++++++----- crates/owlen-tui/src/state/command_palette.rs | 159 ++- crates/owlen-tui/src/ui.rs | 108 +- crates/owlen-tui/tests/chat_snapshots.rs | 33 + ...napshots__command_palette_focus@80x20.snap | 25 + crates/owlen-tui/tests/state_tests.rs | 5 +- 6 files changed, 1111 insertions(+), 386 deletions(-) create mode 100644 crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index c7d2325..c03a730 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -1,347 +1,824 @@ pub mod registry; pub use registry::{AppCommand, CommandRegistry}; -// Command catalog and lookup utilities for the command palette. +use std::cmp::Ordering; -/// Metadata describing a single command keyword. -#[derive(Debug, Clone, Copy)] -pub struct CommandSpec { - pub keyword: &'static str, - pub description: &'static str, +/// High-level category used to group and filter commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCategory { + Session, + Conversation, + Workspace, + Navigation, + Layout, + Models, + Providers, + Tools, + Agent, + Accessibility, + Appearance, + System, + Diagnostics, + Support, } -const COMMANDS: &[CommandSpec] = &[ - CommandSpec { - keyword: "quit", +impl CommandCategory { + pub fn label(self) -> &'static str { + match self { + CommandCategory::Session => "Sessions", + CommandCategory::Conversation => "Conversation", + CommandCategory::Workspace => "Workspace", + CommandCategory::Navigation => "Navigation", + CommandCategory::Layout => "Layout", + CommandCategory::Models => "Models", + CommandCategory::Providers => "Providers", + CommandCategory::Tools => "Tools & Integrations", + CommandCategory::Agent => "Agent", + CommandCategory::Accessibility => "Accessibility", + CommandCategory::Appearance => "Appearance", + CommandCategory::System => "System", + CommandCategory::Diagnostics => "Diagnostics", + CommandCategory::Support => "Support", + } + } +} + +/// Structured preview content rendered alongside the palette. +#[derive(Debug, Clone, Copy)] +pub struct CommandPreview { + pub title: &'static str, + pub body: &'static [&'static str], +} + +/// Rich metadata describing a single command keyword (and optional aliases). +#[derive(Debug, Clone, Copy)] +pub struct CommandDescriptor { + pub keywords: &'static [&'static str], + pub description: &'static str, + pub category: CommandCategory, + pub modes: &'static [&'static str], + pub tags: &'static [&'static str], + pub keybinding: Option<&'static str>, + pub preview: Option<&'static CommandPreview>, +} + +impl CommandDescriptor { + pub fn keywords(&self) -> &[&'static str] { + self.keywords + } + + pub fn primary_keyword(&self) -> &'static str { + self.keywords[0] + } +} + +/// Result returned by [`search`], including the concrete keyword that matched. +#[derive(Debug, Clone, Copy)] +pub struct CommandHit { + pub keyword: &'static str, + pub descriptor: &'static CommandDescriptor, + score: CommandScore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CommandScore { + priority_sum: usize, + primary_sum: usize, + secondary_sum: usize, + alias_index: usize, + keyword_len: usize, + order: usize, +} + +impl Ord for CommandScore { + fn cmp(&self, other: &Self) -> Ordering { + self.priority_sum + .cmp(&other.priority_sum) + .then(self.primary_sum.cmp(&other.primary_sum)) + .then(self.secondary_sum.cmp(&other.secondary_sum)) + .then(self.alias_index.cmp(&other.alias_index)) + .then(self.keyword_len.cmp(&other.keyword_len)) + .then(self.order.cmp(&other.order)) + } +} + +impl PartialOrd for CommandScore { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +const PREVIEW_FILES: CommandPreview = CommandPreview { + title: "Files Panel", + body: &[ + "Toggle the workspace browser and focus the file tree.", + "Shortcuts: f 1 · Ctrl+1 (Vim) · Alt+1 (Emacs).", + "Use /focus to surface related navigation commands.", + ], +}; + +const PREVIEW_MODEL: CommandPreview = CommandPreview { + title: "Model Picker", + body: &[ + "Browse models with fuzzy search, provider filters, and metadata.", + "Shortcuts: m · m (Normal) · Alt+M (Emacs).", + "Type model to jump directly to a specific model.", + ], +}; + +const PREVIEW_PROVIDER: CommandPreview = CommandPreview { + title: "Provider Switcher", + body: &[ + "Swap between Ollama, OpenAI, Anthropic, or MCP providers.", + "Shortcuts: p · Ctrl+X Ctrl+P (Emacs).", + "Append --cloud or --local to filter available models.", + ], +}; + +const COMMANDS: &[CommandDescriptor] = &[ + CommandDescriptor { + keywords: &["quit"], description: "Exit the application", + category: CommandCategory::System, + modes: &["Command"], + tags: &["system", "exit", "shutdown"], + keybinding: Some("Ctrl+C twice"), + preview: None, }, - CommandSpec { - keyword: "q", - description: "Close the active file", - }, - CommandSpec { - keyword: "w", - description: "Save the active file", - }, - CommandSpec { - keyword: "write", - description: "Alias for w", - }, - CommandSpec { - keyword: "clear", + CommandDescriptor { + keywords: &["clear", "c"], description: "Clear the conversation", + category: CommandCategory::Conversation, + modes: &["Command"], + tags: &["conversation", "reset", "history"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "c", - description: "Alias for clear", - }, - CommandSpec { - keyword: "save", - description: "Alias for w", - }, - CommandSpec { - keyword: "wq", - description: "Save and close the active file", - }, - CommandSpec { - keyword: "x", - description: "Alias for wq", - }, - CommandSpec { - keyword: "load", - description: "Load a saved conversation", - }, - CommandSpec { - keyword: "o", - description: "Alias for load", - }, - CommandSpec { - keyword: "open", - description: "Open a file in the code view", - }, - CommandSpec { - keyword: "create", - description: "Create a file (creates missing directories)", - }, - CommandSpec { - keyword: "close", - description: "Close the active code view", - }, - CommandSpec { - keyword: "mode", - description: "Switch operating mode (chat/code)", - }, - CommandSpec { - keyword: "code", - description: "Switch to code mode", - }, - CommandSpec { - keyword: "chat", - description: "Switch to chat mode", - }, - CommandSpec { - keyword: "tools", - description: "List available tools in current mode", - }, - CommandSpec { - keyword: "sessions", - description: "List saved sessions", - }, - CommandSpec { - keyword: "session save", + CommandDescriptor { + keywords: &["session save"], description: "Save the current conversation", + category: CommandCategory::Session, + modes: &["Command"], + tags: &["session", "save", "history"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "help", - description: "Open the help overlay", + CommandDescriptor { + keywords: &["sessions"], + description: "List saved sessions", + category: CommandCategory::Session, + modes: &["Command"], + tags: &["session", "history", "browse"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "h", - description: "Alias for help", + CommandDescriptor { + keywords: &["load", "o"], + description: "Load a saved conversation", + category: CommandCategory::Session, + modes: &["Command"], + tags: &["session", "restore", "history"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "model", - description: "Select a model", - }, - CommandSpec { - keyword: "provider", - description: "Switch provider or set its mode", - }, - CommandSpec { - keyword: "cloud setup", - description: "Configure Ollama Cloud credentials", - }, - CommandSpec { - keyword: "cloud status", - description: "Check Ollama Cloud connectivity", - }, - CommandSpec { - keyword: "cloud models", - description: "List models available in Ollama Cloud", - }, - CommandSpec { - keyword: "cloud logout", - description: "Remove stored Ollama Cloud credentials", - }, - CommandSpec { - keyword: "model info", - description: "Show detailed information for a model", - }, - CommandSpec { - keyword: "model refresh", - description: "Refresh cached model information", - }, - CommandSpec { - keyword: "model details", - description: "Show details for the active model", - }, - CommandSpec { - keyword: "m", - description: "Alias for model", - }, - CommandSpec { - keyword: "models info", - description: "Prefetch detailed information for all models", - }, - CommandSpec { - keyword: "models --local", - description: "Open model picker focused on local models", - }, - CommandSpec { - keyword: "models --cloud", - description: "Open model picker focused on cloud models", - }, - CommandSpec { - keyword: "models --available", - description: "Open model picker showing available models", - }, - CommandSpec { - keyword: "new", + CommandDescriptor { + keywords: &["new", "n"], description: "Start a new conversation", + category: CommandCategory::Conversation, + modes: &["Command"], + tags: &["conversation", "reset", "session"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "n", - description: "Alias for new", + CommandDescriptor { + keywords: &["open"], + description: "Open a file in the code view", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "edit"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "accessibility", - description: "Cycle accessibility presets (default → high contrast → high+reduced)", + CommandDescriptor { + keywords: &["create"], + description: "Create a file (creates missing directories)", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "scaffold"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "accessibility status", - description: "Show high-contrast and reduced chrome settings", + CommandDescriptor { + keywords: &["close", "q"], + description: "Close the active code view", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "close"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "accessibility high on", - description: "Enable high-contrast mode", + CommandDescriptor { + keywords: &["w", "write", "save"], + description: "Save the active file", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "save"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "accessibility high off", - description: "Disable high-contrast mode", + CommandDescriptor { + keywords: &["wq", "x"], + description: "Save and close the active file", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "save", "close"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "accessibility reduced on", - description: "Enable reduced chrome mode", - }, - CommandSpec { - keyword: "accessibility reduced off", - description: "Disable reduced chrome mode", - }, - CommandSpec { - keyword: "accessibility reset", - description: "Restore default accessibility settings", - }, - CommandSpec { - keyword: "theme", - description: "Switch theme", - }, - CommandSpec { - keyword: "themes", - description: "List available themes", - }, - CommandSpec { - keyword: "tutorial", - description: "Show keybinding tutorial", - }, - CommandSpec { - keyword: "reload", - description: "Reload configuration and themes", - }, - CommandSpec { - keyword: "markdown", - description: "Toggle markdown rendering", - }, - CommandSpec { - keyword: "limits", - description: "Show hourly/weekly usage totals", - }, - CommandSpec { - keyword: "web on", - description: "Enable web search tool exposure", - }, - CommandSpec { - keyword: "web off", - description: "Disable web search tool exposure", - }, - CommandSpec { - keyword: "web status", - description: "Show current web search tool state", - }, - CommandSpec { - keyword: "e", - description: "Edit a file", - }, - CommandSpec { - keyword: "edit", - description: "Alias for edit", - }, - CommandSpec { - keyword: "ls", + CommandDescriptor { + keywords: &["ls"], description: "List directory contents", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "list"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "privacy-enable", - description: "Enable a privacy-sensitive tool", + CommandDescriptor { + keywords: &["edit", "e"], + description: "Edit a file", + category: CommandCategory::Workspace, + modes: &["Command"], + tags: &["workspace", "files", "edit"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "privacy-disable", - description: "Disable a privacy-sensitive tool", + CommandDescriptor { + keywords: &["mode"], + description: "Switch operating mode (chat/code)", + category: CommandCategory::System, + modes: &["Command"], + tags: &["mode", "context", "system"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "privacy-clear", - description: "Clear stored secure data", + CommandDescriptor { + keywords: &["code"], + description: "Switch to code mode", + category: CommandCategory::System, + modes: &["Command"], + tags: &["mode", "code", "focus"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "agent", - description: "Enable agent mode for autonomous task execution", + CommandDescriptor { + keywords: &["chat"], + description: "Switch to chat mode", + category: CommandCategory::System, + modes: &["Command"], + tags: &["mode", "chat", "focus"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "stop-agent", - description: "Stop the running agent", + CommandDescriptor { + keywords: &["tools"], + description: "List available tools in the current mode", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["tools", "integration", "automation"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "agent status", - description: "Show current agent status", + CommandDescriptor { + keywords: &["help", "h"], + description: "Open the help overlay", + category: CommandCategory::Support, + modes: &["Normal", "Command"], + tags: &["help", "docs", "support"], + keybinding: Some("F1 / ?"), + preview: None, }, - CommandSpec { - keyword: "agent start", - description: "Arm the agent for the next request", + CommandDescriptor { + keywords: &["tutorial"], + description: "Show keybinding tutorial", + category: CommandCategory::Support, + modes: &["Command"], + tags: &["help", "tutorial", "onboarding"], + keybinding: None, + preview: None, }, - CommandSpec { - keyword: "agent stop", - description: "Stop the running agent", - }, - CommandSpec { - keyword: "layout save", - description: "Persist the current pane layout", - }, - CommandSpec { - keyword: "layout load", - description: "Restore the last saved pane layout", - }, - CommandSpec { - keyword: "keymap", - description: "Show the active keymap profile", - }, - CommandSpec { - keyword: "keymap vim", - description: "Switch to Vim-style key bindings", - }, - CommandSpec { - keyword: "keymap emacs", - description: "Switch to Emacs-style key bindings", - }, - CommandSpec { - keyword: "files", + CommandDescriptor { + keywords: &["files", "explorer"], description: "Toggle the files panel", + category: CommandCategory::Navigation, + modes: &["Normal", "Command"], + tags: &["focus", "panel", "navigation"], + keybinding: Some(" f 1 · Ctrl+1 / Alt+1"), + preview: Some(&PREVIEW_FILES), }, - CommandSpec { - keyword: "explorer", - description: "Alias for files", - }, - CommandSpec { - keyword: "debug log", + CommandDescriptor { + keywords: &["debug log"], description: "Toggle the debug log panel", + category: CommandCategory::Diagnostics, + modes: &["Normal", "Command"], + tags: &["debug", "logs", "diagnostics"], + keybinding: Some("F12"), + preview: None, + }, + CommandDescriptor { + keywords: &["layout save"], + description: "Persist the current pane layout", + category: CommandCategory::Layout, + modes: &["Command"], + tags: &["layout", "workspace", "save"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["layout load"], + description: "Restore the last saved pane layout", + category: CommandCategory::Layout, + modes: &["Command"], + tags: &["layout", "workspace", "restore"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["model", "m"], + description: "Select a model", + category: CommandCategory::Models, + modes: &["Normal", "Command"], + tags: &["model", "focus", "selection"], + keybinding: Some(" m · m / Alt+M"), + preview: Some(&PREVIEW_MODEL), + }, + CommandDescriptor { + keywords: &["models --local"], + description: "Open model picker focused on local models", + category: CommandCategory::Models, + modes: &["Command"], + tags: &["model", "local", "selection"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["models --cloud"], + description: "Open model picker focused on cloud models", + category: CommandCategory::Models, + modes: &["Command"], + tags: &["model", "cloud", "selection"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["models --available"], + description: "Open model picker showing available models", + category: CommandCategory::Models, + modes: &["Command"], + tags: &["model", "availability", "selection"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["models info"], + description: "Prefetch detailed information for all models", + category: CommandCategory::Models, + modes: &["Command"], + tags: &["model", "metadata", "prefetch"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["model info", "model details"], + description: "Show detailed information for a model", + category: CommandCategory::Models, + modes: &["Command"], + tags: &["model", "metadata", "panel"], + keybinding: Some("i / r (Model picker)"), + preview: None, + }, + CommandDescriptor { + keywords: &["model refresh"], + description: "Refresh cached model information", + category: CommandCategory::Models, + modes: &["Command"], + tags: &["model", "metadata", "refresh"], + keybinding: Some("r (Model picker)"), + preview: None, + }, + CommandDescriptor { + keywords: &["provider"], + description: "Switch provider or set its mode", + category: CommandCategory::Providers, + modes: &["Normal", "Command"], + tags: &["provider", "focus", "selection"], + keybinding: Some(" p · Ctrl+X Ctrl+P"), + preview: Some(&PREVIEW_PROVIDER), + }, + CommandDescriptor { + keywords: &["cloud setup"], + description: "Configure Ollama Cloud credentials", + category: CommandCategory::Providers, + modes: &["Command"], + tags: &["provider", "cloud", "auth"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["cloud status"], + description: "Check Ollama Cloud connectivity", + category: CommandCategory::Providers, + modes: &["Command"], + tags: &["provider", "cloud", "status"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["cloud models"], + description: "List models available in Ollama Cloud", + category: CommandCategory::Providers, + modes: &["Command"], + tags: &["provider", "cloud", "models"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["cloud logout"], + description: "Remove stored Ollama Cloud credentials", + category: CommandCategory::Providers, + modes: &["Command"], + tags: &["provider", "cloud", "auth"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["theme"], + description: "Switch to a specific theme", + category: CommandCategory::Appearance, + modes: &["Command"], + tags: &["appearance", "theme", "customise"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["themes"], + description: "List available themes", + category: CommandCategory::Appearance, + modes: &["Command"], + tags: &["appearance", "theme", "list"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["reload"], + description: "Reload configuration and themes", + category: CommandCategory::System, + modes: &["Command"], + tags: &["system", "config", "reload"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["markdown"], + description: "Toggle markdown rendering", + category: CommandCategory::Appearance, + modes: &["Command"], + tags: &["appearance", "formatting", "markdown"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["limits"], + description: "Show hourly/weekly usage totals", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["usage", "quota", "analytics"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["web on"], + description: "Enable web search tool exposure", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["tools", "web", "search"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["web off"], + description: "Disable web search tool exposure", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["tools", "web", "search"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["web status"], + description: "Show current web search tool state", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["tools", "web", "status"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["privacy-enable"], + description: "Enable a privacy-sensitive tool", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["privacy", "tools", "security"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["privacy-disable"], + description: "Disable a privacy-sensitive tool", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["privacy", "tools", "security"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["privacy-clear"], + description: "Clear stored secure data", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["privacy", "tools", "security"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["agent"], + description: "Enable agent mode for autonomous task execution", + category: CommandCategory::Agent, + modes: &["Command"], + tags: &["agent", "automation", "workflow"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["agent start"], + description: "Arm the agent for the next request", + category: CommandCategory::Agent, + modes: &["Command"], + tags: &["agent", "automation", "start"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["agent stop", "stop-agent"], + description: "Stop the running agent", + category: CommandCategory::Agent, + modes: &["Command"], + tags: &["agent", "automation", "stop"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["agent status"], + description: "Show current agent status", + category: CommandCategory::Agent, + modes: &["Command"], + tags: &["agent", "automation", "status"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility"], + description: "Cycle accessibility presets (default → high contrast → high+reduced)", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "contrast", "preset"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility status"], + description: "Show high-contrast and reduced chrome settings", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "status", "contrast"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility high on"], + description: "Enable high-contrast mode", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "contrast", "high"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility high off"], + description: "Disable high-contrast mode", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "contrast", "high"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility reduced on"], + description: "Enable reduced chrome mode", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "reduced", "chrome"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility reduced off"], + description: "Disable reduced chrome mode", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "reduced", "chrome"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["accessibility reset"], + description: "Restore default accessibility settings", + category: CommandCategory::Accessibility, + modes: &["Command"], + tags: &["accessibility", "reset", "defaults"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["keymap"], + description: "Show the active keymap profile", + category: CommandCategory::System, + modes: &["Command"], + tags: &["keymap", "keyboard", "profile"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["keymap vim"], + description: "Switch to Vim-style key bindings", + category: CommandCategory::System, + modes: &["Command"], + tags: &["keymap", "vim", "keyboard"], + keybinding: None, + preview: None, + }, + CommandDescriptor { + keywords: &["keymap emacs"], + description: "Switch to Emacs-style key bindings", + category: CommandCategory::System, + modes: &["Command"], + tags: &["keymap", "emacs", "keyboard"], + keybinding: None, + preview: None, }, ]; -/// Return the static catalog of commands. -pub fn all() -> &'static [CommandSpec] { +/// Expose the static command catalog. +pub fn catalog() -> &'static [CommandDescriptor] { COMMANDS } -/// Return the default suggestion list (all command keywords). -pub fn default_suggestions() -> Vec { - COMMANDS.to_vec() -} +/// Search the command catalog using fuzzy matching across keywords, tags, and descriptions. +pub fn search<'a>(terms: &[&'a str], tags: &[&'a str]) -> Vec { + let mut results: Vec = Vec::new(); -/// Generate keyword suggestions for the given input. -pub fn suggestions(input: &str) -> Vec { - let trimmed = input.trim(); - if trimmed.is_empty() { - return default_suggestions(); + for (order, descriptor) in COMMANDS.iter().enumerate() { + if !matches_tags(descriptor, tags) { + continue; + } + + if terms.is_empty() { + results.push(CommandHit { + keyword: descriptor.primary_keyword(), + descriptor, + score: CommandScore { + priority_sum: 0, + primary_sum: 0, + secondary_sum: 0, + alias_index: 0, + keyword_len: descriptor.primary_keyword().len(), + order, + }, + }); + continue; + } + + for (alias_index, keyword) in descriptor.keywords.iter().enumerate() { + if let Some(score) = compute_score(descriptor, keyword, terms, order, alias_index) { + results.push(CommandHit { + keyword, + descriptor, + score, + }); + } + } } - let mut matches: Vec<(usize, usize, CommandSpec)> = COMMANDS - .iter() - .filter_map(|spec| { - match_score(spec.keyword, trimmed).map(|score| (score.0, score.1, *spec)) - }) - .collect(); - - if matches.is_empty() { - return default_suggestions(); - } - - matches.sort_by(|a, b| { - a.0.cmp(&b.0) - .then(a.1.cmp(&b.1)) - .then(a.2.keyword.cmp(b.2.keyword)) + results.sort_by(|a, b| { + let cmp = a.score.cmp(&b.score); + if cmp == Ordering::Equal { + a.keyword.cmp(b.keyword) + } else { + cmp + } }); - - matches.into_iter().map(|(_, _, spec)| spec).collect() + results } +fn matches_tags(descriptor: &CommandDescriptor, filters: &[&str]) -> bool { + if filters.is_empty() { + return true; + } + + filters.iter().all(|filter| { + let filter = filter.trim(); + if filter.is_empty() { + return true; + } + + let category_match = descriptor.category.label().eq_ignore_ascii_case(filter); + if category_match { + return true; + } + + descriptor + .tags + .iter() + .any(|tag| tag.eq_ignore_ascii_case(filter)) + }) +} + +fn compute_score( + descriptor: &CommandDescriptor, + keyword: &str, + terms: &[&str], + order: usize, + alias_index: usize, +) -> Option { + let mut priority_sum = 0usize; + let mut primary_sum = 0usize; + let mut secondary_sum = 0usize; + + for term in terms { + let term = term.trim(); + if term.is_empty() { + continue; + } + + let mut best: Option<(usize, usize, usize)> = None; + + let mut consider = |candidate: &str, priority: usize| { + if candidate.is_empty() { + return; + } + if let Some((primary, secondary)) = match_score(candidate, term) { + let candidate_score = (priority, primary, secondary); + best = Some(match best { + Some(current) if current <= candidate_score => current, + _ => candidate_score, + }); + } + }; + + consider(keyword, 0); + for token in keyword + .split(|c: char| c.is_whitespace() || c == '-' || c == '_') + .filter(|token| !token.is_empty()) + { + consider(token, 0); + } + for tag in descriptor.tags { + consider(tag, 1); + } + consider(descriptor.description, 2); + consider(descriptor.category.label(), 3); + if let Some(binding) = descriptor.keybinding { + consider(binding, 4); + } + + let (priority, primary, secondary) = best?; + + priority_sum += priority; + primary_sum += primary; + secondary_sum += secondary; + } + + Some(CommandScore { + priority_sum, + primary_sum, + secondary_sum, + alias_index, + keyword_len: keyword.len(), + order, + }) +} + +/// Compute a fuzzy ranking between a candidate string and a query token. pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> { let query = query.trim(); if query.is_empty() { @@ -368,47 +845,6 @@ pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> { } } -#[cfg(test)] -#[allow(clippy::items_after_test_module)] -mod tests { - use super::*; - - #[test] - fn suggestions_prioritize_agent_start() { - let results = suggestions("agent st"); - assert!(!results.is_empty()); - assert_eq!(results[0].keyword, "agent start"); - assert!(results.iter().any(|spec| spec.keyword == "agent stop")); - } - - #[test] - fn suggestions_include_limits_command() { - let results = suggestions("li"); - assert!(!results.is_empty()); - assert_eq!(results[0].keyword, "limits"); - assert!(results.iter().any(|spec| spec.keyword == "limits")); - } - - #[test] - fn suggestions_include_web_variants() { - let results = suggestions("web"); - assert!(results.iter().any(|spec| spec.keyword == "web on")); - assert!(results.iter().any(|spec| spec.keyword == "web off")); - assert!(results.iter().any(|spec| spec.keyword == "web status")); - } - - #[test] - fn suggestions_include_accessibility_commands() { - let results = suggestions("acce"); - assert!(results.iter().any(|spec| spec.keyword == "accessibility")); - assert!( - results - .iter() - .any(|spec| spec.keyword == "accessibility high on") - ); - } -} - fn is_subsequence(text: &str, pattern: &str) -> bool { if pattern.is_empty() { return true; @@ -431,3 +867,54 @@ fn is_subsequence(text: &str, pattern: &str) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + fn lower_terms(input: &str) -> Vec { + input + .split_whitespace() + .map(|s| s.to_ascii_lowercase()) + .collect() + } + + #[test] + fn search_prefers_agent_start() { + let terms_owned = lower_terms("agent st"); + let term_refs: Vec<&str> = terms_owned.iter().map(|s| s.as_str()).collect(); + let hits = search(&term_refs, &[]); + assert!(!hits.is_empty()); + let top_two: Vec<&str> = hits.iter().take(2).map(|hit| hit.keyword).collect(); + assert!(top_two.contains(&"agent start")); + assert!(top_two.contains(&"agent stop")); + } + + #[test] + fn tag_filter_limits_results() { + let hits = search(&[], &["agent"]); + assert!(!hits.is_empty()); + assert!(hits.iter().all(|hit| { + hit.descriptor + .tags + .iter() + .any(|tag| tag.eq_ignore_ascii_case("agent")) + })); + } + + #[test] + fn metadata_has_basic_attributes() { + for descriptor in catalog() { + assert!( + !descriptor.tags.is_empty(), + "command '{}' is missing tags", + descriptor.primary_keyword() + ); + assert!( + !descriptor.modes.is_empty(), + "command '{}' is missing modes", + descriptor.primary_keyword() + ); + } + } +} diff --git a/crates/owlen-tui/src/state/command_palette.rs b/crates/owlen-tui/src/state/command_palette.rs index 41cce24..c8a320a 100644 --- a/crates/owlen-tui/src/state/command_palette.rs +++ b/crates/owlen-tui/src/state/command_palette.rs @@ -1,4 +1,4 @@ -use crate::commands::{self, CommandSpec}; +use crate::commands; use std::collections::{HashSet, VecDeque}; const MAX_RESULTS: usize = 12; @@ -25,6 +25,17 @@ pub struct PaletteSuggestion { pub label: String, pub detail: Option, pub group: PaletteGroup, + pub category: Option, + pub modes: Vec, + pub keybinding: Option, + pub tags: Vec, + pub preview: Option, +} + +#[derive(Debug, Clone)] +pub struct PalettePreview { + pub title: String, + pub body: Vec, } #[derive(Debug, Clone)] @@ -165,7 +176,8 @@ impl CommandPalette { } fn dynamic_suggestions(&self, trimmed: &str) -> Vec { - let lowered = trimmed.to_ascii_lowercase(); + let query = QueryParts::from_input(trimmed); + let lowered = query.text.to_ascii_lowercase(); let mut results: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); @@ -184,30 +196,36 @@ impl CommandPalette { } } - let history = self.history_suggestions(trimmed); + let history = self.history_suggestions(&query); push_entries(&mut results, &mut seen, history); if results.len() >= MAX_RESULTS { return results; } + if !query.tags.is_empty() && query.terms.is_empty() { + // Only tag filters are active; restrict results to matching commands. + push_entries(&mut results, &mut seen, self.command_entries(&query)); + return results; + } + if lowered.starts_with("model ") { - let rest = trimmed[5..].trim(); + let rest = query.text.get(5..).unwrap_or_default().trim(); push_entries( &mut results, &mut seen, self.model_suggestions("model", rest), ); if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + push_entries(&mut results, &mut seen, self.command_entries(&query)); } return results; } if lowered.starts_with("m ") { - let rest = trimmed[2..].trim(); + let rest = query.text.get(2..).unwrap_or_default().trim(); push_entries(&mut results, &mut seen, self.model_suggestions("m", rest)); if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + push_entries(&mut results, &mut seen, self.command_entries(&query)); } return results; } @@ -215,20 +233,20 @@ impl CommandPalette { if lowered == "model" { push_entries(&mut results, &mut seen, self.model_suggestions("model", "")); if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + push_entries(&mut results, &mut seen, self.command_entries(&query)); } return results; } if lowered.starts_with("provider ") { - let rest = trimmed[9..].trim(); + let rest = query.text.get(9..).unwrap_or_default().trim(); push_entries( &mut results, &mut seen, self.provider_suggestions("provider", rest), ); if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + push_entries(&mut results, &mut seen, self.command_entries(&query)); } return results; } @@ -240,37 +258,41 @@ impl CommandPalette { self.provider_suggestions("provider", ""), ); if results.len() < MAX_RESULTS { - push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + push_entries(&mut results, &mut seen, self.command_entries(&query)); } return results; } // General query – combine commands, models, and providers using fuzzy order. - push_entries(&mut results, &mut seen, self.command_entries(trimmed)); - if results.len() < MAX_RESULTS { + push_entries(&mut results, &mut seen, self.command_entries(&query)); + if results.len() < MAX_RESULTS && query.tags.is_empty() { push_entries( &mut results, &mut seen, - self.model_suggestions("model", trimmed), + self.model_suggestions("model", query.text.trim()), ); } - if results.len() < MAX_RESULTS { + if results.len() < MAX_RESULTS && query.tags.is_empty() { push_entries( &mut results, &mut seen, - self.provider_suggestions("provider", trimmed), + self.provider_suggestions("provider", query.text.trim()), ); } results } - fn history_suggestions(&self, query: &str) -> Vec { + fn history_suggestions(&self, query: &QueryParts) -> Vec { if self.history.is_empty() { return Vec::new(); } - if query.trim().is_empty() { + if !query.tags.is_empty() && query.terms.is_empty() { + return Vec::new(); + } + + if query.text.trim().is_empty() { return self .history .iter() @@ -281,6 +303,11 @@ impl CommandPalette { label: value.to_string(), detail: Some("Recent command".to_string()), group: PaletteGroup::History, + category: None, + modes: vec![], + keybinding: None, + tags: vec!["history".to_string()], + preview: None, }) .collect(); } @@ -291,7 +318,7 @@ impl CommandPalette { .rev() .enumerate() .filter_map(|(recency, value)| { - commands::match_score(value, query) + commands::match_score(value, query.text.as_str()) .map(|(primary, secondary)| (primary, secondary, recency, value)) }) .collect(); @@ -306,19 +333,45 @@ impl CommandPalette { label: value.to_string(), detail: Some("Recent command".to_string()), group: PaletteGroup::History, + category: None, + modes: vec![], + keybinding: None, + tags: vec!["history".to_string()], + preview: None, }) .collect() } - fn command_entries(&self, query: &str) -> Vec { - let specs: Vec = commands::suggestions(query); - specs - .into_iter() - .map(|spec| PaletteSuggestion { - value: spec.keyword.to_string(), - label: spec.keyword.to_string(), - detail: Some(spec.description.to_string()), - group: PaletteGroup::Command, + fn command_entries(&self, query: &QueryParts) -> Vec { + let term_refs: Vec<&str> = query.terms.iter().map(|s| s.as_str()).collect(); + let tag_refs: Vec<&str> = query.tags.iter().map(|s| s.as_str()).collect(); + let hits = commands::search(&term_refs, &tag_refs); + + hits.into_iter() + .map(|hit| { + let descriptor = hit.descriptor; + PaletteSuggestion { + value: hit.keyword.to_string(), + label: hit.keyword.to_string(), + detail: Some(descriptor.description.to_string()), + group: PaletteGroup::Command, + category: Some(descriptor.category.label().to_string()), + modes: descriptor + .modes + .iter() + .map(|mode| mode.to_string()) + .collect(), + keybinding: descriptor.keybinding.map(|binding| binding.to_string()), + tags: descriptor.tags.iter().map(|tag| tag.to_string()).collect(), + preview: descriptor.preview.map(|preview| PalettePreview { + title: preview.title.to_string(), + body: preview + .body + .iter() + .map(|line| (*line).to_string()) + .collect(), + }), + } }) .collect() } @@ -334,6 +387,11 @@ impl CommandPalette { label: entry.display_name().to_string(), detail: Some(format!("Model · {}", entry.provider)), group: PaletteGroup::Model, + category: Some("Models".to_string()), + modes: vec!["Command".to_string()], + keybinding: None, + tags: vec!["model".to_string()], + preview: None, }) .collect(); } @@ -361,6 +419,11 @@ impl CommandPalette { label: entry.display_name().to_string(), detail: Some(format!("Model · {}", entry.provider)), group: PaletteGroup::Model, + category: Some("Models".to_string()), + modes: vec!["Command".to_string()], + keybinding: None, + tags: vec!["model".to_string()], + preview: None, }) .collect() } @@ -376,6 +439,11 @@ impl CommandPalette { label: provider.to_string(), detail: Some("Provider".to_string()), group: PaletteGroup::Provider, + category: Some("Providers".to_string()), + modes: vec!["Command".to_string()], + keybinding: None, + tags: vec!["provider".to_string()], + preview: None, }) .collect(); } @@ -398,6 +466,11 @@ impl CommandPalette { label: provider.to_string(), detail: Some("Provider".to_string()), group: PaletteGroup::Provider, + category: Some("Providers".to_string()), + modes: vec!["Command".to_string()], + keybinding: None, + tags: vec!["provider".to_string()], + preview: None, }) .collect() } @@ -437,3 +510,33 @@ mod tests { assert_eq!(history_entries[0].value, "OPEN FOO.RS"); } } + +#[derive(Debug, Default)] +struct QueryParts { + text: String, + terms: Vec, + tags: Vec, +} + +impl QueryParts { + fn from_input(input: &str) -> Self { + let mut raw_terms: Vec<&str> = Vec::new(); + let mut terms: Vec = Vec::new(); + let mut tags: Vec = Vec::new(); + + for token in input.split_whitespace() { + if let Some(stripped) = token.strip_prefix('/') { + let tag = stripped.trim(); + if !tag.is_empty() { + tags.push(tag.to_ascii_lowercase()); + } + } else { + raw_terms.push(token); + terms.push(token.to_ascii_lowercase()); + } + } + + let text = raw_terms.join(" "); + Self { text, terms, tags } + } +} diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index c164868..04f28b5 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -5381,13 +5381,27 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { .style(Style::default().bg(palette.highlight).fg(palette.label)); frame.render_widget(input, layout[0]); - let selected_index = if suggestions.is_empty() { - None + let (selected_index, selected_preview) = if suggestions.is_empty() { + (None, None) } else { - Some( - app.selected_suggestion() - .min(suggestions.len().saturating_sub(1)), - ) + let idx = app + .selected_suggestion() + .min(suggestions.len().saturating_sub(1)); + let preview = suggestions + .get(idx) + .and_then(|suggestion| suggestion.preview.as_ref()); + (Some(idx), preview) + }; + + let show_preview = selected_preview.is_some() && layout[1].width > 60; + let (list_area, preview_area) = if show_preview { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(62), Constraint::Percentage(38)]) + .split(layout[1]); + (columns[0], Some(columns[1])) + } else { + (layout[1], None) }; if suggestions.is_empty() { @@ -5407,6 +5421,7 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let mut items: Vec = Vec::new(); let mut previous_group: Option = None; + let accent = Style::default().fg(theme.info); for (idx, suggestion) in suggestions.iter().enumerate() { let mut lines: Vec = Vec::new(); @@ -5420,7 +5435,7 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { previous_group = Some(suggestion.group); } - let label_line = Line::from(vec![ + let mut label_spans = vec![ Span::styled( if Some(idx) == selected_index { "›" @@ -5436,8 +5451,13 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { suggestion.label.clone(), Style::default().add_modifier(Modifier::BOLD), ), - ]); - lines.push(label_line); + ]; + + if let Some(binding) = &suggestion.keybinding { + label_spans.push(Span::raw(" ")); + label_spans.push(Span::styled(binding.clone(), accent)); + } + lines.push(Line::from(label_spans)); if let Some(detail) = &suggestion.detail { lines.push(Line::from(Span::styled( @@ -5448,6 +5468,40 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { ))); } + let mut meta_spans: Vec = vec![Span::raw(" ")]; + let mut has_meta = false; + if let Some(category) = &suggestion.category { + meta_spans.push(Span::styled(category.clone(), accent)); + has_meta = true; + } + if !suggestion.modes.is_empty() { + if has_meta { + meta_spans.push(Span::raw(" · ")); + } + meta_spans.push(Span::styled( + format!("Modes: {}", suggestion.modes.join(", ")), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::ITALIC), + )); + has_meta = true; + } + if !suggestion.tags.is_empty() { + if has_meta { + meta_spans.push(Span::raw(" · ")); + } + meta_spans.push(Span::styled( + format!("#{}", suggestion.tags.join(" #")), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM), + )); + has_meta = true; + } + if has_meta { + lines.push(Line::from(meta_spans)); + } + let item = ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label)); items.push(item); @@ -5460,18 +5514,38 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { .highlight_style(highlight) .style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_stateful_widget(list, layout[1], &mut list_state); + frame.render_stateful_widget(list, list_area, &mut list_state); + + if let (Some(area), Some(preview)) = (preview_area, selected_preview) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.focused_panel_border)) + .title(Span::styled( + format!(" {} ", preview.title), + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(palette.active).fg(palette.label)); + + frame.render_widget(block.clone(), area); + let inner = block.inner(area); + if inner.width > 0 && inner.height > 0 { + let lines: Vec = preview + .body + .iter() + .map(|line| Line::from(Span::raw(line.clone()))) + .collect(); + let preview_paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(palette.active).fg(palette.label)); + frame.render_widget(preview_paragraph, inner); + } + } } - let instructions = "Enter: run · Tab: autocomplete · Esc: cancel"; - let detail_text = selected_index - .and_then(|idx| suggestions.get(idx)) - .and_then(|item| item.detail.as_deref()) - .map(|detail| format!("{detail} — {instructions}")) - .unwrap_or_else(|| instructions.to_string()); + let instructions = "Enter: run · Tab: autocomplete · /tag filter · Esc: cancel"; let footer = Paragraph::new(Line::from(Span::styled( - detail_text, + instructions, Style::default().fg(palette.label), ))) .alignment(Alignment::Center) diff --git a/crates/owlen-tui/tests/chat_snapshots.rs b/crates/owlen-tui/tests/chat_snapshots.rs index 5574fdb..cfd95bc 100644 --- a/crates/owlen-tui/tests/chat_snapshots.rs +++ b/crates/owlen-tui/tests/chat_snapshots.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use insta::{assert_snapshot, with_settings}; use owlen_core::{ Config, Mode, Provider, @@ -10,6 +11,7 @@ use owlen_core::{ ui::{NoOpUiController, UiController}, }; use owlen_tui::ChatApp; +use owlen_tui::events::Event; use owlen_tui::ui::render_chat; use ratatui::{Terminal, backend::TestBackend}; use tempfile::tempdir; @@ -197,3 +199,34 @@ async fn render_chat_tool_call_snapshot() { assert_snapshot!("chat_tool_call_snapshot", snapshot); }); } + +#[tokio::test(flavor = "multi_thread")] +async fn render_command_palette_focus_snapshot() { + let mut app = build_chat_app(|_| {}).await; + + app.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char(':'), + KeyModifiers::NONE, + ))) + .await + .expect("enter command mode"); + + for ch in ['f', 'o', 'c', 'u', 's'] { + app.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))) + .await + .expect("type query"); + } + + // Highlight the second suggestion (typically the model picker preview). + app.handle_event(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))) + .await + .expect("move selection"); + + with_settings!({ snapshot_suffix => "80x20" }, { + let snapshot = render_snapshot(&mut app, 80, 20); + assert_snapshot!("command_palette_focus", snapshot); + }); +} diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap new file mode 100644 index 0000000..05917bd --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__command_palette_focus@80x20.snap @@ -0,0 +1,25 @@ +--- +source: crates/owlen-tui/tests/chat_snapshots.rs +assertion_line: 230 +expression: snapshot +--- +" Command Palette Ctrl+P " +" " +" :focus " +" " +" " +" Commands " +" code " +" Switch to code mode " +" System · Modes: Command · #mode #code #focus " +" › chat " +" Switch to chat mode " +" System · Modes: Command · #mode #chat #focus " +" files f 1 · Ctrl+1 / Alt+1 " +" Toggle the files panel " +" Navigation · Modes: Normal, Command · #focus #panel #navigation " +" " +" " +" Enter: run · Tab: autocomplete · /tag filter · Esc: cancel " +" " +" " diff --git a/crates/owlen-tui/tests/state_tests.rs b/crates/owlen-tui/tests/state_tests.rs index f631669..3890e20 100644 --- a/crates/owlen-tui/tests/state_tests.rs +++ b/crates/owlen-tui/tests/state_tests.rs @@ -47,7 +47,10 @@ fn palette_apply_selected_updates_buffer() { #[test] fn command_catalog_contains_expected_aliases() { - let keywords: Vec<_> = commands::all().iter().map(|spec| spec.keyword).collect(); + let mut keywords: Vec<&str> = Vec::new(); + for descriptor in commands::catalog() { + keywords.extend_from_slice(descriptor.keywords()); + } assert!(keywords.contains(&"model")); assert!(keywords.contains(&"open")); assert!(keywords.contains(&"close"));