feat(keymap): rebuild modal binding registry

Acceptance Criteria:\n- Declarative keymap sequences replace pending_key/pending_focus_chord logic.\n- :keymap show streams the active binding table and bindings export reflects new schema.\n- Vim/Emacs default keymaps resolve multi-step chords via the registry.

Test Notes:\n- cargo test -p owlen-tui
This commit is contained in:
2025-10-25 09:12:14 +02:00
parent f592840d39
commit 2d45406982
7 changed files with 761 additions and 246 deletions

78
agents-2025-10-25.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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<ControllerEvent>,
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
@@ -590,8 +590,7 @@ pub struct ChatApp {
current_thinking: Option<String>, // Current thinking content from last assistant message
// Holds the latest formatted Agentic ReAct actions (thought/action/observation)
agent_actions: Option<String>,
pending_key: Option<char>, // 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<FileActionPrompt>, // Active file action prompt
command_palette: CommandPalette, // Command mode state (buffer + suggestions)
resource_catalog: Vec<McpResourceConfig>, // 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<Instant>, // 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<PaneDirection>,
@@ -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<bool> {
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<bool> {
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("<none>")
} 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();

View File

@@ -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 }
}

View File

@@ -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<InputMode, KeymapNode>,
default_timeout: Duration,
bindings: Vec<ResolvedBinding>,
}
#[derive(Debug, Default, Clone)]
struct KeymapNode {
command: Option<AppCommand>,
timeout: Option<Duration>,
children: HashMap<KeyPattern, KeymapNode>,
}
#[derive(Debug, Clone)]
struct ResolvedBinding {
mode: InputMode,
sequence: Vec<KeyPattern>,
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<InputMode, KeymapNode> = 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<AppCommand> {
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<KeyBindingConfig>,
}
pub fn describe_bindings(&self) -> Vec<KeymapBindingDescription> {
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<String>),
}
let now = Instant::now();
state.expire_if_needed(now);
impl KeyList {
fn into_iter(self) -> Vec<String> {
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<KeyPattern>,
deadline: Option<Instant>,
}
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<String> {
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<KeyBindingConfig>,
}
#[derive(Debug, Deserialize)]
struct KeyBindingConfig {
#[serde(default)]
mode: Option<String>,
#[serde(default)]
modes: Vec<String>,
command: String,
#[serde(default, rename = "keys")]
keys: Option<KeyList>,
#[serde(default)]
sequence: Option<SequenceSpec>,
#[serde(default)]
sequences: Vec<SequenceSpec>,
#[serde(default)]
timeout_ms: Option<u64>,
}
impl KeyBindingConfig {
fn resolve_modes(&self) -> Vec<String> {
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<Vec<String>> {
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<String>),
}
impl SequenceSpec {
fn into_sequence(self) -> Vec<String> {
match self {
SequenceSpec::Single(value) => vec![value],
SequenceSpec::Sequence(values) => values,
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
enum KeyList {
Single(String),
Multiple(Vec<String>),
}
impl KeyList {
fn into_iter(self) -> Vec<String> {
match self {
KeyList::Single(key) => vec![key],
KeyList::Multiple(keys) => keys,
}
}
}
fn insert_sequence(
root: &mut KeymapNode,
sequence: &[KeyPattern],
command: AppCommand,
timeout: Option<Duration>,
) {
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::<Vec<_>>()
);
}
} else {
node.command = Some(command);
}
}
fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option<KeyCodeKind> {
@@ -247,7 +556,7 @@ fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option<KeyCodeK
fn parse_mode(mode: &str) -> Option<InputMode> {
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<String>,
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, &registry);
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, &registry);
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"), &registry);
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)
));
}
}

View File

@@ -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,

View File

@@ -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,