diff --git a/agents-2025-10-25.md b/agents-2025-10-25.md new file mode 100644 index 0000000..ea6e717 --- /dev/null +++ b/agents-2025-10-25.md @@ -0,0 +1,78 @@ +## audit(keys): current bindings & commands +- Default Vim profile only maps 13 actions (model picker, focus cycling, palette, debug) leaving core editor/navigation keys hard-coded elsewhere, limiting customisation and discoverability (`crates/owlen-tui/keymap.toml:1`). +- Emacs profile mirrors the same minimal surface with Alt-based chords, lacking text editing staples like yank/kill rings or buffer navigation (`crates/owlen-tui/keymap_emacs.toml:1`). +- Normal-mode logic wires multi-key sequences (`gg`, `dd`, `Ctrl+W` splits) and focus chords directly in Rust, bypassing the registry and preventing remapping (`crates/owlen-tui/src/chat_app.rs:5691`). +- Command palette exposes 52 keywords but lacks grouping, fuzzy tags, and modern discoverability patterns (`crates/owlen-tui/src/commands/mod.rs:12`). +- Pane management, resize, and accessibility toggles rely on bespoke status messaging with limited visual affordances, creating inconsistency across panels (`crates/owlen-tui/src/chat_app.rs:5800`). + +## research recap (vim/emacs & modern TUI UX) +- Contemporary TUI stacks (e.g., Textual) ship keymap registries with runtime swapping, fuzzy command launcher, and discoverable overlays, setting a parity bar for custom apps. +- GNU Emacs emphasises mnemonic, consistent command families (`C-x` for control commands, `M-` for meta navigation) and discourages overloading chords without documentation. +- Vim ecosystem guidance encourages using `:` commands plus modal consistency, avoiding unremappable hard-coded sequences to keep leader mappings customisable. +- Modern UX trends for 2025 favour adaptive layouts, depth via dupal layers, and clear affordances even in minimalist shells, suggesting richer theming, animation, and responsive scaffolding. +- Emerging terminal UIs adopt layered “glass + neon” aesthetics with light/dark parity and accessible contrasts instead of monochrome blocks. +- CLI design critiques highlight the need for preview panes, keyboard hints, and status context to bridge novice gaps. + +## roadmap (Owlen UX parity sprint) + +### 1) feat(keymap): rebuild binding registry with modal coverage +- **Status:** Complete — unified keymap tree with sequence-aware matching, binding export, and runtime introspection. +- **Highlights:** + 1. Replaced ad-hoc `pending_key`/`pending_focus_chord` logic with a trie-driven resolver supporting arbitrary sequences, timeouts, and per-mode registries (`crates/owlen-tui/src/state/keymap.rs`). + 2. Extended TOML schema (`crates/owlen-tui/keymap.toml`) to declare multi-step chords (`gg`, `dd`, `Ctrl+W s`, `Ctrl+K ←`), mapping them to new `AppCommand` variants consumed in `chat_app`. + 3. Added `:keymap show` to stream the active binding table into the transcript, aiding discoverability and regression review (`crates/owlen-tui/src/chat_app.rs`). +- **Tests:** `cargo test -p owlen-tui` (covers keymap parser + sequence resolution). + +### 2) feat(keymap): enhance Emacs profile & leader ergonomics +- **Why:** Match Emacs conventions (C-x, C-c prefixes, yank/kill, buffer switching) and provide Vim leaders for workspace, tools, search. +- **Implement:** Flesh out Emacs profile with kill-ring commands, register `C-x C-f`, `C-x C-s`, `M-g` jumps, etc. Introduce configurable Vim leader (default space) with namespaced commands for provider/model, tools, layout. +- **AC:** Built-in keymaps cover ≥90% of documented commands; help overlay lists canonical chords. +- **Tests:** Snapshot tests for `:keymap vim|emacs`, runtime switch coverage, toml schema validation. + +### 3) feat(commands): modern palette with tagging & fuzzy discovery +- **Why:** Current flat list scales poorly; modern TUIs expose grouped, searchable command centers with context previews. +- **Implement:** Introduce metadata (category, mode availability, keybinding). Replace static array with data-driven registry backing palette cards, filters, quick docs. Add inline preview for high-impact commands (`:files`, `:model`). +- **AC:** Typing `focus` shows grouped matches with key hints; palette supports tag filters (e.g., `/agent`). +- **Tests:** Command search integration test, snapshot for palette rendering, lint verifying metadata completeness. + +### 4) feat(tui): adaptive layout & polish refresh +- **Why:** Align Owlen with 2025 TUI styling (responsive spacing, depth cues, animated transitions) while preserving accessibility. +- **Implement:** Build layout system with breakpoints (≤80 cols stacked, ≥120 cols split). Introduce layer tokens (frosted glass, neon accent) with configurable shadows, transitions, and focus rings. Add optional micro-animations for context gauges, pane toggles. +- **AC:** UI adapts seamlessly across 80/100/140-col snapshots; focus state and warnings remain perceivable in high-contrast mode. +- **Tests:** Ratatui snapshot suite for multiple terminal sizes/themes; golden tests for animation fallbacks when disabled. + +### 5) feat(guidance): inline cheat-sheets & onboarding +- **Why:** Keyboard-rich TUIs need embedded guidance to bridge novices; industry examples surface dynamic hints and quick tours. +- **Implement:** Contextual overlay triggered via `?` showing active keymap, top commands, search tips; first-run coach marks for focus chords and leader keys. Persist completion flags in config. +- **AC:** First launch displays onboarding flow; `?` overlay updates with keymap swaps. +- **Tests:** Snapshot coverage for overlays; integration test ensuring onboarding flag persists. + +### 6) feat(tui): status surface & toast overhaul +- **Why:** Current status text is terse; need richer multi-line feedback with icons, timers, action shortcuts. +- **Implement:** Replace single-line status with layered HUD (primary message, subtext, progress). Add toast API with severity icons, auto-dismiss timers, and keyboard hints. +- **AC:** Streaming, tool calls, rate limits show structured cards; toasts accessible via history panel. +- **Tests:** Unit tests for toast queue, snapshot for HUD states, integration verifying keyboard hints update per keymap. + +### 7) docs(tui): publish UX & keybinding playbook +- **Why:** Documenting conventions reduces drift and eases contributor onboarding; aligns with Emacs/Vim guidelines. +- **Implement:** Author guide covering modal philosophy, command metadata schema, theming tokens, animation policy. Include migration notes for custom keymaps. +- **AC:** Docs indexed from README, changelog summarises parity steps. +- **Tests:** Link checker; sample custom keymap validated in CI. + +### 8) test(tui): automated UX regression suite +- **Why:** Large visual refactor demands deterministic screenshots across modes & terminal sizes. +- **Implement:** Expand `chat_snapshots.rs` to cover normal/insert/visual/command states, both keymaps, accessibility toggles, and responsive breakpoints. Wire into CI with diff artifact upload. +- **AC:** Snapshot set protects ≥90% of critical panels; CI fails on visual drift. +- **Tests:** `cargo test -p owlen-tui --test chat_snapshots` with feature gates for animations. + +### 9) chore(assets): scripted screenshot pipeline +- **Why:** We need gallery-quality imagery for docs/changelog while keeping it reproducible. +- **Implement:** Add a demo harness that stages representative scenes (model picker, usage header, tool call, accessibility modes) using deterministic seeds; render frames via ratatui `TestBackend`, export ANSI dumps, and convert to PNG with an ANSI→image tool (e.g., chafa). Provide a `cargo xtask screenshots` command and update docs to reference the generated assets. +- **AC:** Running `cargo xtask screenshots` regenerates all gallery files; README/docs reference the latest images. +- **Tests:** CI smoke test invoking the harness (PNG conversion optional via feature flag); checksum guard to detect drift. + +### 10) feat(compression): adaptive transcript compactor with auto-toggle +- **Why:** Long chats blow past context windows; we need an owned compression pipeline that keeps history usable without manual pruning. +- **Implement:** Build a compression service that chunks stale turns, summarises via chosen model/tool, and rewrites history entries. Auto mode enabled by default; expose config flag (`chat.auto_compress = true`), CLI opt-out (`--no-auto-compress`), and runtime command (`:compress auto on|off`, `:compress now`). Provide granular settings (threshold tokens, model override, strategy). +- **AC:** Owlen transparently compresses once history exceeds threshold; users can toggle via config/CLI/command and view status. Compression metadata logged for audit. +- **Tests:** Unit test strategy selection; integration harness simulating long conversations verifying auto-trigger, manual command, and CLI flag precedence. diff --git a/crates/owlen-tui/keymap.toml b/crates/owlen-tui/keymap.toml index 7d926b2..0c6e9c4 100644 --- a/crates/owlen-tui/keymap.toml +++ b/crates/owlen-tui/keymap.toml @@ -1,99 +1,152 @@ [[binding]] -mode = "normal" -keys = ["m"] +modes = ["normal"] +sequence = ["m"] command = "model.open_all" [[binding]] -mode = "normal" -keys = ["Ctrl+Shift+L"] +modes = ["normal"] +sequence = ["Ctrl+Shift+L"] command = "model.open_local" [[binding]] -mode = "normal" -keys = ["Ctrl+Shift+C"] +modes = ["normal"] +sequence = ["Ctrl+Shift+C"] command = "model.open_cloud" [[binding]] -mode = "normal" -keys = ["Ctrl+Shift+P"] +modes = ["normal"] +sequence = ["Ctrl+Shift+P"] command = "model.open_available" [[binding]] -mode = "normal" -keys = ["Ctrl+P"] +modes = ["normal", "editing"] +sequence = ["Ctrl+P"] command = "palette.open" [[binding]] -mode = "editing" -keys = ["Ctrl+P"] -command = "palette.open" - -[[binding]] -mode = "normal" -keys = ["Tab"] +modes = ["normal"] +sequence = ["Tab"] command = "focus.next" [[binding]] -mode = "normal" -keys = ["Shift+Tab"] +modes = ["normal"] +sequence = ["Shift+Tab"] command = "focus.prev" [[binding]] -mode = "normal" -keys = ["Ctrl+1"] +modes = ["normal"] +sequence = ["Ctrl+1"] command = "focus.files" [[binding]] -mode = "normal" -keys = ["Ctrl+2"] +modes = ["normal"] +sequence = ["Ctrl+2"] command = "focus.chat" [[binding]] -mode = "normal" -keys = ["Ctrl+3"] +modes = ["normal"] +sequence = ["Ctrl+3"] command = "focus.code" [[binding]] -mode = "normal" -keys = ["Ctrl+4"] +modes = ["normal"] +sequence = ["Ctrl+4"] command = "focus.thinking" [[binding]] -mode = "normal" -keys = ["Ctrl+5"] +modes = ["normal"] +sequence = ["Ctrl+5"] command = "focus.input" [[binding]] -mode = "editing" -keys = ["Enter"] +modes = ["editing"] +sequence = ["Enter"] command = "composer.submit" [[binding]] -mode = "normal" -keys = ["Ctrl+;"] +modes = ["normal"] +sequence = ["Ctrl+;"] command = "mode.command" [[binding]] -mode = "normal" -keys = ["F12"] +modes = ["normal", "editing", "visual", "command", "help"] +sequence = ["F12"] command = "debug.toggle" [[binding]] -mode = "editing" -keys = ["F12"] -command = "debug.toggle" +modes = ["normal"] +sequence = ["g", "g"] +command = "navigate.top" [[binding]] -mode = "visual" -keys = ["F12"] -command = "debug.toggle" +modes = ["normal"] +sequence = ["g", "t"] +command = "files.focus_expand" [[binding]] -mode = "command" -keys = ["F12"] -command = "debug.toggle" +modes = ["normal"] +sequence = ["g", "T"] +command = "files.focus_expand" [[binding]] -mode = "help" -keys = ["F12"] -command = "debug.toggle" +modes = ["normal"] +sequence = ["g", "h"] +command = "files.toggle_hidden" + +[[binding]] +modes = ["normal"] +sequence = ["g", "H"] +command = "files.toggle_hidden" + +[[binding]] +modes = ["normal"] +sequence = ["d", "d"] +command = "input.clear" + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+W", "s"] +command = "workspace.split_horizontal" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+W", "S"] +command = "workspace.split_horizontal" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+W", "v"] +command = "workspace.split_vertical" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+W", "V"] +command = "workspace.split_vertical" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+K", "Left"] +command = "workspace.focus_left" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+K", "Right"] +command = "workspace.focus_right" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+K", "Up"] +command = "workspace.focus_up" +timeout_ms = 1200 + +[[binding]] +modes = ["normal"] +sequence = ["Ctrl+K", "Down"] +command = "workspace.focus_down" +timeout_ms = 1200 diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index e052159..4c6a690 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -56,10 +56,10 @@ use crate::model_info_panel::ModelInfoPanel; use crate::slash::{self, McpSlashCommand, SlashCommand}; use crate::state::{ CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, - FileNode, FileTreeState, Keymap, KeymapProfile, ModelPaletteEntry, PaletteSuggestion, - PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, - SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, install_global_logger, - spawn_repo_search_task, spawn_symbol_search_task, + FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapProfile, KeymapState, + ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, + RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, + install_global_logger, spawn_repo_search_task, spawn_symbol_search_task, }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::{format_token_short, format_tool_output}; @@ -100,7 +100,6 @@ const TUTORIAL_SYSTEM_STATUS: &str = const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; -const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200); const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); const RESIZE_STEP: f32 = 0.05; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; @@ -582,6 +581,7 @@ pub struct ChatApp { mvu_model: AppModel, keymap: Keymap, current_keymap_profile: KeymapProfile, + keymap_state: KeymapState, controller_event_rx: mpsc::UnboundedReceiver, pending_llm_request: bool, // Flag to indicate LLM request needs to be processed pending_tool_execution: Option<(Uuid, Vec)>, // Pending tool execution (message_id, tool_calls) @@ -590,8 +590,7 @@ pub struct ChatApp { current_thinking: Option, // Current thinking content from last assistant message // Holds the latest formatted Agentic ReAct actions (thought/action/observation) agent_actions: Option, - pending_key: Option, // For multi-key sequences like gg, dd - clipboard: String, // Vim-style clipboard for yank/paste + clipboard: String, // Vim-style clipboard for yank/paste pending_file_action: Option, // Active file action prompt command_palette: CommandPalette, // Command mode state (buffer + suggestions) resource_catalog: Vec, // Configured MCP resources for autocompletion @@ -611,7 +610,6 @@ pub struct ChatApp { chat_line_offset: usize, // Number of leading lines trimmed for scrollback thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) code_workspace: CodeWorkspace, // Code views with tabs/splits - pending_focus_chord: Option, // Tracks Ctrl+K focus chord timeout last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection resize_snap_index: usize, // Cycles through 25/50/75 snaps last_snap_direction: Option, @@ -869,6 +867,7 @@ impl ChatApp { mvu_model: AppModel::default(), keymap, current_keymap_profile, + keymap_state: KeymapState::default(), controller_event_rx, pending_llm_request: false, pending_tool_execution: None, @@ -876,7 +875,6 @@ impl ChatApp { is_loading: false, current_thinking: None, agent_actions: None, - pending_key: None, clipboard: String::new(), pending_file_action: None, command_palette: CommandPalette::new(), @@ -897,7 +895,6 @@ impl ChatApp { chat_line_offset: 0, thinking_cursor: (0, 0), code_workspace: CodeWorkspace::new(), - pending_focus_chord: None, last_resize_tap: None, resize_snap_index: 0, last_snap_direction: None, @@ -2032,6 +2029,7 @@ impl ChatApp { self.reset_model_picker_state(); } self.mode = mode; + self.keymap_state.reset(); let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode })); } @@ -3810,18 +3808,38 @@ impl ChatApp { } async fn try_execute_command(&mut self, key: &KeyEvent) -> Result { - if let Some(command) = self.keymap.resolve(self.mode, key) { - if self.execute_command(command).await? { + match self.keymap.step(self.mode, key, &mut self.keymap_state) { + KeymapEventResult::Matched(command) => { + if self.execute_command(command).await? { + return Ok(true); + } + } + KeymapEventResult::Pending => { + self.update_pending_sequence_status(); return Ok(true); } + KeymapEventResult::NoMatch => {} } + Ok(false) } + fn update_pending_sequence_status(&mut self) { + if self.keymap_state.matches_sequence(&["Ctrl+W"]) { + self.status = "Split layout: press s for horizontal, v for vertical".to_string(); + self.error = None; + } else if self.keymap_state.matches_sequence(&["Ctrl+K"]) { + if self.show_model_info && self.model_info_viewport_height > 0 { + self.model_info_panel.scroll_up(); + } + self.status = "Pane focus pending — use ←/→/↑/↓".to_string(); + self.error = None; + } + } + async fn execute_command(&mut self, command: AppCommand) -> Result { match command { AppCommand::OpenModelPicker(filter) => { - self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } @@ -3831,7 +3849,6 @@ impl ChatApp { Ok(true) } AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => { - self.pending_key = None; if !matches!( self.mode, InputMode::Normal | InputMode::Editing | InputMode::Command @@ -3846,7 +3863,6 @@ impl ChatApp { Ok(true) } AppCommand::CycleFocusForward => { - self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } @@ -3856,7 +3872,6 @@ impl ChatApp { Ok(true) } AppCommand::CycleFocusBackward => { - self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } @@ -3866,7 +3881,6 @@ impl ChatApp { Ok(true) } AppCommand::FocusPanel(target) => { - self.pending_key = None; if !matches!(self.mode, InputMode::Normal) { return Ok(false); } @@ -3897,24 +3911,83 @@ impl ChatApp { if !matches!(self.mode, InputMode::Editing) { return Ok(false); } - self.pending_key = None; self.sync_textarea_to_buffer(); let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit)); self.handle_app_effects(effects).await?; Ok(true) } AppCommand::ToggleDebugLog => { - self.pending_key = None; self.toggle_debug_log_panel(); Ok(true) } AppCommand::SetKeymap(profile) => { - self.pending_key = None; if profile.is_builtin() { self.switch_keymap_profile(profile).await?; } Ok(true) } + AppCommand::JumpToTop => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.jump_to_top(); + Ok(true) + } + AppCommand::ExpandFilePanel => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + if self.focus_panel(FocusedPanel::Files) { + self.status = "Files panel focused".to_string(); + self.error = None; + } else { + self.status = "Unable to focus Files panel".to_string(); + } + Ok(true) + } + AppCommand::ToggleHiddenFiles => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + if matches!(self.focused_panel, FocusedPanel::Files) { + self.toggle_hidden_files(); + } else { + self.status = "Toggle hidden files from the Files panel".to_string(); + } + Ok(true) + } + AppCommand::SplitPaneHorizontal => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.split_active_pane(SplitAxis::Horizontal); + Ok(true) + } + AppCommand::SplitPaneVertical => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.split_active_pane(SplitAxis::Vertical); + Ok(true) + } + AppCommand::ClearInputBuffer => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.input_buffer_mut().clear(); + self.textarea = TextArea::default(); + configure_textarea_defaults(&mut self.textarea); + self.status = "Input buffer cleared".to_string(); + self.error = None; + Ok(true) + } + AppCommand::MoveWorkspaceFocus(direction) => { + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.handle_workspace_focus_move(direction); + Ok(true) + } } } @@ -4282,7 +4355,6 @@ impl ChatApp { } fn handle_workspace_focus_move(&mut self, direction: PaneDirection) { - self.pending_focus_chord = None; if self.code_workspace.move_focus(direction) { self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); @@ -4303,7 +4375,6 @@ impl ChatApp { } fn handle_workspace_resize(&mut self, direction: PaneDirection) { - self.pending_focus_chord = None; let now = Instant::now(); let is_double = self .last_resize_tap @@ -5702,82 +5773,12 @@ impl ChatApp { return Ok(AppState::Running); } - if let Some(started) = self.pending_focus_chord { - if started.elapsed() > FOCUS_CHORD_TIMEOUT { - self.pending_focus_chord = None; - } else if key.modifiers.is_empty() { - let direction = match key.code { - KeyCode::Left => Some(PaneDirection::Left), - KeyCode::Right => Some(PaneDirection::Right), - KeyCode::Up => Some(PaneDirection::Up), - KeyCode::Down => Some(PaneDirection::Down), - _ => None, - }; - if let Some(direction) = direction { - self.handle_workspace_focus_move(direction); - return Ok(AppState::Running); - } else { - self.pending_focus_chord = None; - } - } else { - self.pending_focus_chord = None; - } - } - - if let Some(pending) = self.pending_key { - self.pending_key = None; - match (pending, key.code) { - ('g', KeyCode::Char('g')) => { - self.jump_to_top(); - } - ('g', KeyCode::Char('T')) | ('g', KeyCode::Char('t')) => { - self.expand_file_panel(); - self.focused_panel = FocusedPanel::Files; - self.status = "Files panel focused".to_string(); - } - ('g', KeyCode::Char('h')) | ('g', KeyCode::Char('H')) => { - if matches!(self.focused_panel, FocusedPanel::Files) { - self.toggle_hidden_files(); - } else { - self.status = - "Toggle hidden files from the Files panel".to_string(); - } - } - ('W', KeyCode::Char('s')) | ('W', KeyCode::Char('S')) => { - self.split_active_pane(SplitAxis::Horizontal); - } - ('W', KeyCode::Char('v')) | ('W', KeyCode::Char('V')) => { - self.split_active_pane(SplitAxis::Vertical); - } - ('d', KeyCode::Char('d')) => { - // Clear input buffer - self.input_buffer_mut().clear(); - self.textarea = TextArea::default(); - configure_textarea_defaults(&mut self.textarea); - self.status = "Input buffer cleared".to_string(); - } - _ => { - // Invalid sequence, ignore - } - } - return Ok(AppState::Running); - } - if matches!(self.focused_panel, FocusedPanel::Files) && self.handle_file_panel_key(&key).await? { return Ok(AppState::Running); } - if key.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key.code, KeyCode::Char('w') | KeyCode::Char('W')) - { - self.pending_key = Some('W'); - self.status = - "Split layout: press s for horizontal, v for vertical".to_string(); - return Ok(AppState::Running); - } - match (key.code, key.modifiers) { (KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => { self.handle_workspace_resize(PaneDirection::Left); @@ -5830,16 +5831,6 @@ impl ChatApp { .scroll_down(self.model_info_viewport_height); } } - (KeyCode::Char('k'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.pending_focus_chord = Some(Instant::now()); - self.status = "Pane focus pending — use ←/→/↑/↓".to_string(); - if self.show_model_info && self.model_info_viewport_height > 0 { - self.model_info_panel.scroll_up(); - } - return Ok(AppState::Running); - } // Mode switches (KeyCode::Char('v'), KeyModifiers::NONE) => { if matches!(self.focused_panel, FocusedPanel::Code) { @@ -6208,15 +6199,6 @@ impl ChatApp { (KeyCode::Char('G'), KeyModifiers::SHIFT) => { self.jump_to_bottom(); } - // Multi-key sequences - (KeyCode::Char('g'), KeyModifiers::NONE) => { - self.pending_key = Some('g'); - self.status = "g".to_string(); - } - (KeyCode::Char('d'), KeyModifiers::NONE) => { - self.pending_key = Some('d'); - self.status = "d".to_string(); - } // Yank/paste (works from any panel) (KeyCode::Char('p'), KeyModifiers::NONE) => { if !self.clipboard.is_empty() { @@ -6330,12 +6312,9 @@ impl ChatApp { return Ok(AppState::Running); } (KeyCode::Esc, KeyModifiers::NONE) => { - self.pending_key = None; self.set_input_mode(InputMode::Normal); } - _ => { - self.pending_key = None; - } + _ => {} } } InputMode::RepoSearch => match (key.code, key.modifiers) { @@ -7980,22 +7959,49 @@ impl ChatApp { } } "keymap" => { - if let Some(arg) = args.first() { - match KeymapProfile::from_str(arg) { - Some(profile) if profile.is_builtin() => { - self.switch_keymap_profile(profile).await?; - } - Some(_) => { - self.error = Some( - "Custom keymaps must be configured via keymap_path".to_string(), - ); - } - None => { - self.error = Some(format!( - "Unknown keymap profile: {}", - arg + if let Some(arg) = args.first().copied() { + if arg.eq_ignore_ascii_case("show") + || arg.eq_ignore_ascii_case("list") + { + let mut lines = + String::from("Active keymap bindings:\n"); + lines.push_str("Mode Sequence Command\n"); + lines.push_str("------------------------------------------------\n"); + for binding in self.keymap.describe_bindings() { + let mode_label = format!("{:?}", binding.mode); + let sequence = if binding.sequence.is_empty() { + String::from("") + } else { + binding.sequence.join(" ") + }; + lines.push_str(&format!( + "{:<14} {:<24} {}\n", + mode_label, sequence, binding.command )); } + self.controller + .conversation_mut() + .push_system_message(lines); + self.status = "Keymap bindings listed in conversation" + .to_string(); + self.error = None; + } else { + match KeymapProfile::from_str(arg) { + Some(profile) if profile.is_builtin() => { + self.switch_keymap_profile(profile).await?; + } + Some(_) => { + self.error = Some( + "Custom keymaps must be configured via keymap_path".to_string(), + ); + } + None => { + self.error = Some(format!( + "Unknown keymap profile: {}", + arg + )); + } + } } } else { self.status = format!( @@ -8408,14 +8414,12 @@ impl ChatApp { } } MouseEventKind::Down(MouseButton::Left) => { - self.pending_key = None; if let Some(region) = region { self.handle_mouse_click(region, mouse.column, mouse.row); } } MouseEventKind::Drag(MouseButton::Left) => { if matches!(region, Some(UiRegion::Input)) { - self.pending_key = None; self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row); } } @@ -10853,7 +10857,7 @@ impl ChatApp { self.pending_tool_execution = None; self.pending_consent = None; self.queued_consents.clear(); - self.pending_key = None; + self.keymap_state.reset(); self.visual_start = None; self.visual_end = None; self.clipboard.clear(); diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs index c8d46ba..f11fed8 100644 --- a/crates/owlen-tui/src/commands/registry.rs +++ b/crates/owlen-tui/src/commands/registry.rs @@ -2,7 +2,10 @@ use std::collections::HashMap; use owlen_core::ui::FocusedPanel; -use crate::{state::KeymapProfile, widgets::model_picker::FilterMode}; +use crate::{ + state::{KeymapProfile, PaneDirection}, + widgets::model_picker::FilterMode, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AppCommand { @@ -15,6 +18,13 @@ pub enum AppCommand { EnterCommandMode, ToggleDebugLog, SetKeymap(KeymapProfile), + JumpToTop, + ExpandFilePanel, + ToggleHiddenFiles, + SplitPaneHorizontal, + SplitPaneVertical, + ClearInputBuffer, + MoveWorkspaceFocus(PaneDirection), } #[derive(Debug)] @@ -76,6 +86,40 @@ impl CommandRegistry { "keymap.set_emacs".to_string(), AppCommand::SetKeymap(KeymapProfile::Emacs), ); + commands.insert("navigate.top".to_string(), AppCommand::JumpToTop); + commands.insert( + "files.focus_expand".to_string(), + AppCommand::ExpandFilePanel, + ); + commands.insert( + "files.toggle_hidden".to_string(), + AppCommand::ToggleHiddenFiles, + ); + commands.insert( + "workspace.split_horizontal".to_string(), + AppCommand::SplitPaneHorizontal, + ); + commands.insert( + "workspace.split_vertical".to_string(), + AppCommand::SplitPaneVertical, + ); + commands.insert("input.clear".to_string(), AppCommand::ClearInputBuffer); + commands.insert( + "workspace.focus_left".to_string(), + AppCommand::MoveWorkspaceFocus(PaneDirection::Left), + ); + commands.insert( + "workspace.focus_right".to_string(), + AppCommand::MoveWorkspaceFocus(PaneDirection::Right), + ); + commands.insert( + "workspace.focus_up".to_string(), + AppCommand::MoveWorkspaceFocus(PaneDirection::Up), + ); + commands.insert( + "workspace.focus_down".to_string(), + AppCommand::MoveWorkspaceFocus(PaneDirection::Down), + ); Self { commands } } diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs index b55ae23..1f78c4e 100644 --- a/crates/owlen-tui/src/state/keymap.rs +++ b/crates/owlen-tui/src/state/keymap.rs @@ -2,6 +2,7 @@ use std::{ collections::HashMap, fs, path::{Path, PathBuf}, + time::{Duration, Instant}, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -13,11 +14,28 @@ use crate::commands::registry::{AppCommand, CommandRegistry}; const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml"); const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml"); +const DEFAULT_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1200); #[derive(Debug, Clone)] pub struct Keymap { - bindings: HashMap<(InputMode, KeyPattern), AppCommand>, profile: KeymapProfile, + trees: HashMap, + default_timeout: Duration, + bindings: Vec, +} + +#[derive(Debug, Default, Clone)] +struct KeymapNode { + command: Option, + timeout: Option, + children: HashMap, +} + +#[derive(Debug, Clone)] +struct ResolvedBinding { + mode: InputMode, + sequence: Vec, + command_name: String, } impl Keymap { @@ -43,77 +61,215 @@ impl Keymap { let (parsed, profile) = loader.finish(); - let mut bindings = HashMap::new(); + let mut trees: HashMap = HashMap::new(); + let mut bindings = Vec::new(); for entry in parsed.bindings { - let mode = match parse_mode(&entry.mode) { - Some(mode) => mode, - None => { - warn!("Unknown input mode '{}' in keymap binding", entry.mode); - continue; - } - }; + let modes: Vec<_> = entry + .resolve_modes() + .into_iter() + .filter_map(|mode| parse_mode(&mode)) + .collect(); + if modes.is_empty() { + warn!( + "Unknown input modes in keymap binding for command '{}'", + entry.command + ); + continue; + } let command = match registry.resolve(&entry.command) { - Some(cmd) => cmd, + Some(command) => command, None => { warn!("Unknown command '{}' in keymap binding", entry.command); continue; } }; - for key in entry.keys.into_iter() { - match KeyPattern::from_str(&key) { - Some(pattern) => { - bindings.insert((mode, pattern), command); + let sequences = entry.resolve_sequences(); + if sequences.is_empty() { + warn!( + "No key sequence defined for command '{}' (modes: {:?})", + entry.command, modes + ); + continue; + } + + let timeout = entry.timeout_ms.map(Duration::from_millis); + + for mut sequence_tokens in sequences { + let mut sequence = Vec::new(); + let mut parse_failed = false; + for token in sequence_tokens.drain(..) { + match KeyPattern::from_str(&token) { + Some(pattern) => sequence.push(pattern), + None => { + warn!( + "Unrecognised key specification '{}' for command '{}'", + token, entry.command + ); + parse_failed = true; + break; + } } - None => warn!( - "Unrecognised key specification '{}' for mode {}", - key, entry.mode - ), + } + if parse_failed || sequence.is_empty() { + continue; + } + + for mode in &modes { + let tree = trees.entry(*mode).or_default(); + insert_sequence(tree, &sequence, command, timeout); + bindings.push(ResolvedBinding { + mode: *mode, + sequence: sequence.clone(), + command_name: entry.command.clone(), + }); } } } - Self { bindings, profile } - } - - pub fn resolve(&self, mode: InputMode, event: &KeyEvent) -> Option { - let pattern = KeyPattern::from_event(event)?; - self.bindings.get(&(mode, pattern)).copied() + Self { + profile, + trees, + default_timeout: DEFAULT_SEQUENCE_TIMEOUT, + bindings, + } } pub fn profile(&self) -> KeymapProfile { self.profile } -} -#[derive(Debug, Deserialize)] -struct KeymapConfig { - #[serde(default, rename = "binding")] - bindings: Vec, -} + pub fn describe_bindings(&self) -> Vec { + let mut descriptions: Vec<_> = self + .bindings + .iter() + .map(|binding| KeymapBindingDescription { + mode: binding.mode, + sequence: binding + .sequence + .iter() + .map(|pattern| pattern.display_token()) + .collect(), + command: binding.command_name.clone(), + }) + .collect(); + descriptions.sort_by(|a, b| { + let mode_cmp = format!("{:?}", a.mode).cmp(&format!("{:?}", b.mode)); + if mode_cmp != std::cmp::Ordering::Equal { + return mode_cmp; + } + let seq_a = a.sequence.join(" "); + let seq_b = b.sequence.join(" "); + let seq_cmp = seq_a.cmp(&seq_b); + if seq_cmp != std::cmp::Ordering::Equal { + return seq_cmp; + } + a.command.cmp(&b.command) + }); + descriptions + } -#[derive(Debug, Deserialize)] -struct KeyBindingConfig { - mode: String, - command: String, - keys: KeyList, -} + pub fn step( + &self, + mode: InputMode, + event: &KeyEvent, + state: &mut KeymapState, + ) -> KeymapEventResult { + let pattern = match KeyPattern::from_event(event) { + Some(pattern) => pattern, + None => { + state.reset(); + return KeymapEventResult::NoMatch; + } + }; -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum KeyList { - Single(String), - Multiple(Vec), -} + let now = Instant::now(); + state.expire_if_needed(now); -impl KeyList { - fn into_iter(self) -> Vec { - match self { - KeyList::Single(key) => vec![key], - KeyList::Multiple(keys) => keys, + state.sequence.push(pattern); + let mut node = self.node_for_sequence(mode, &state.sequence); + + if node.is_none() { + state.reset(); + state.sequence.push(pattern); + node = self.node_for_sequence(mode, &state.sequence); + if node.is_none() { + state.reset(); + return KeymapEventResult::NoMatch; + } } + + let node = node.unwrap(); + let timeout = node.timeout.unwrap_or(self.default_timeout); + + if let Some(command) = node.command { + state.reset(); + return KeymapEventResult::Matched(command); + } + + if node.children.is_empty() { + state.reset(); + return KeymapEventResult::NoMatch; + } + + state.deadline = Some(now + timeout); + KeymapEventResult::Pending + } + + fn node_for_sequence(&self, mode: InputMode, sequence: &[KeyPattern]) -> Option<&KeymapNode> { + let mut node = self.trees.get(&mode)?; + for pattern in sequence { + node = node.children.get(pattern)?; + } + Some(node) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeymapEventResult { + Matched(AppCommand), + Pending, + NoMatch, +} + +#[derive(Default, Debug)] +pub struct KeymapState { + sequence: Vec, + deadline: Option, +} + +impl KeymapState { + pub fn reset(&mut self) { + self.sequence.clear(); + self.deadline = None; + } + + fn expire_if_needed(&mut self, now: Instant) { + if let Some(deadline) = self.deadline { + if now > deadline { + self.reset(); + } + } + } + + pub fn sequence_tokens(&self) -> Vec { + self.sequence + .iter() + .map(|pattern| pattern.display_token()) + .collect() + } + + pub fn matches_sequence(&self, tokens: &[&str]) -> bool { + if self.sequence.len() != tokens.len() { + return false; + } + tokens.iter().enumerate().all(|(idx, token)| { + KeyPattern::from_str(token) + .map(|pattern| pattern == self.sequence[idx]) + .unwrap_or(false) + }) } } @@ -199,6 +355,159 @@ impl KeyPattern { modifiers: normalize_modifiers(modifiers), }) } + + fn display_token(&self) -> String { + let mut parts = Vec::new(); + if self.modifiers.contains(KeyModifiers::CONTROL) { + parts.push("Ctrl".to_string()); + } + if self.modifiers.contains(KeyModifiers::ALT) { + parts.push("Alt".to_string()); + } + if self.modifiers.contains(KeyModifiers::SHIFT) { + parts.push("Shift".to_string()); + } + + let key = match self.code { + KeyCodeKind::Char(c) => c.to_string(), + KeyCodeKind::Enter => "Enter".to_string(), + KeyCodeKind::Tab => "Tab".to_string(), + KeyCodeKind::BackTab => "BackTab".to_string(), + KeyCodeKind::Backspace => "Backspace".to_string(), + KeyCodeKind::Esc => "Esc".to_string(), + KeyCodeKind::Up => "Up".to_string(), + KeyCodeKind::Down => "Down".to_string(), + KeyCodeKind::Left => "Left".to_string(), + KeyCodeKind::Right => "Right".to_string(), + KeyCodeKind::PageUp => "PageUp".to_string(), + KeyCodeKind::PageDown => "PageDown".to_string(), + KeyCodeKind::Home => "Home".to_string(), + KeyCodeKind::End => "End".to_string(), + KeyCodeKind::F(n) => format!("F{}", n), + }; + parts.push(key); + parts.join("+") + } +} + +#[derive(Debug, Deserialize)] +struct KeymapConfig { + #[serde(default, rename = "binding")] + bindings: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeyBindingConfig { + #[serde(default)] + mode: Option, + #[serde(default)] + modes: Vec, + command: String, + #[serde(default, rename = "keys")] + keys: Option, + #[serde(default)] + sequence: Option, + #[serde(default)] + sequences: Vec, + #[serde(default)] + timeout_ms: Option, +} + +impl KeyBindingConfig { + fn resolve_modes(&self) -> Vec { + if !self.modes.is_empty() { + self.modes.clone() + } else if let Some(mode) = &self.mode { + vec![mode.clone()] + } else { + Vec::new() + } + } + + fn resolve_sequences(&self) -> Vec> { + let mut result = Vec::new(); + + if let Some(keys) = self.keys.clone() { + for key in keys.into_iter() { + result.push(vec![key]); + } + } + + if let Some(seq) = self.sequence.clone() { + result.push(seq.into_sequence()); + } + + for seq in self.sequences.clone() { + result.push(seq.into_sequence()); + } + + result + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +enum SequenceSpec { + Single(String), + Sequence(Vec), +} + +impl SequenceSpec { + fn into_sequence(self) -> Vec { + match self { + SequenceSpec::Single(value) => vec![value], + SequenceSpec::Sequence(values) => values, + } + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +enum KeyList { + Single(String), + Multiple(Vec), +} + +impl KeyList { + fn into_iter(self) -> Vec { + match self { + KeyList::Single(key) => vec![key], + KeyList::Multiple(keys) => keys, + } + } +} + +fn insert_sequence( + root: &mut KeymapNode, + sequence: &[KeyPattern], + command: AppCommand, + timeout: Option, +) { + let mut node = root; + for pattern in sequence.iter() { + let child = node.children.entry(*pattern).or_default(); + if let Some(duration) = timeout { + child.timeout = match child.timeout { + Some(existing) if existing <= duration => Some(existing), + _ => Some(duration), + }; + } + node = child; + } + + if let Some(existing) = node.command { + if existing != command { + warn!( + "Keymap conflict: multiple commands mapped to sequence {:?}", + sequence + .iter() + .map(|pattern| pattern.display_token()) + .collect::>() + ); + } + } else { + node.command = Some(command); + } } fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option { @@ -247,7 +556,7 @@ fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option Option { match mode.to_ascii_lowercase().as_str() { "normal" => Some(InputMode::Normal), - "editing" => Some(InputMode::Editing), + "editing" | "insert" => Some(InputMode::Editing), "command" => Some(InputMode::Command), "visual" => Some(InputMode::Visual), "provider_selection" | "provider" => Some(InputMode::ProviderSelection), @@ -394,6 +703,13 @@ impl KeymapLoader { } } +#[derive(Debug, Clone)] +pub struct KeymapBindingDescription { + pub mode: InputMode, + pub sequence: Vec, + pub command: String, +} + #[cfg(test)] mod tests { use super::*; @@ -402,20 +718,38 @@ mod tests { #[test] fn resolve_binding_from_default_keymap() { let registry = CommandRegistry::new(); - assert!(registry.resolve("model.open_all").is_some()); - let parsed: KeymapConfig = toml::from_str(DEFAULT_KEYMAP).unwrap(); - assert!(!parsed.bindings.is_empty()); let keymap = Keymap::load(None, None, ®istry); + let mut state = KeymapState::default(); let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); - assert!( - !keymap.bindings.is_empty(), - "expected default keymap to provide bindings" + let result = keymap.step(InputMode::Normal, &event, &mut state); + assert!(matches!( + result, + KeymapEventResult::Matched(AppCommand::OpenModelPicker(None)) + )); + } + + #[test] + fn resolves_multi_key_sequence() { + let registry = CommandRegistry::new(); + let keymap = Keymap::load(None, None, ®istry); + let mut state = KeymapState::default(); + + let first = keymap.step( + InputMode::Normal, + &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + &mut state, ); - assert_eq!( - keymap.resolve(InputMode::Normal, &event), - Some(AppCommand::OpenModelPicker(None)) + assert!(matches!(first, KeymapEventResult::Pending)); + assert!(state.matches_sequence(&["g"])); + + let second = keymap.step( + InputMode::Normal, + &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + &mut state, ); + assert!(matches!(second, KeymapEventResult::Matched(_))); + assert!(state.sequence_tokens().is_empty()); } #[test] @@ -423,13 +757,15 @@ mod tests { let registry = CommandRegistry::new(); let keymap = Keymap::load(None, Some("emacs"), ®istry); assert_eq!(keymap.profile(), KeymapProfile::Emacs); - assert!( - keymap - .resolve( - InputMode::Normal, - &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT) - ) - .is_some() + let mut state = KeymapState::default(); + let result = keymap.step( + InputMode::Normal, + &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT), + &mut state, ); + assert!(matches!( + result, + KeymapEventResult::Matched(AppCommand::EnterCommandMode) + )); } } diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index 25c1354..af26523 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -19,7 +19,7 @@ pub use file_icons::{FileIconResolver, FileIconSet, IconDetection}; pub use file_tree::{ FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, }; -pub use keymap::{Keymap, KeymapProfile}; +pub use keymap::{Keymap, KeymapBindingDescription, KeymapEventResult, KeymapProfile, KeymapState}; pub use search::{ RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind, RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState, diff --git a/crates/owlen-tui/src/state/workspace.rs b/crates/owlen-tui/src/state/workspace.rs index 670581e..0147765 100644 --- a/crates/owlen-tui/src/state/workspace.rs +++ b/crates/owlen-tui/src/state/workspace.rs @@ -5,7 +5,7 @@ use owlen_core::state::AutoScroll; use serde::{Deserialize, Serialize}; /// Cardinal direction used for navigating between panes or resizing splits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PaneDirection { Left, Right,