From 25db19fc0ed050fe812607b21698a345039d520d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 13 Jun 2026 14:46:04 +0200 Subject: [PATCH] vim normal-mode navigation in non-editor panes (T-406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural T-403 child: make vim normal mode mean navigation in panes that were mouse-only. The passive global key path can't run multi-chord sequences (D-82), so each pane hosts its own SequenceMatcher — factored into a reusable PaneKeyNav that resolves the live keymap and dispatches nav.* intents while a pane holds focus under the vim preset. - nav.* intents (down/up/pageDown/pageUp/top/bottom/expandOrRight/ collapseOrLeft/activate) — preset-neutral; vim.yaml binds j/k/ctrl+d/ctrl+u/ gg/G/l/h/[o,enter] under `vim.normal && !editor.focused`. - The editor publishes an `editor.focused` scope flag from its focus node, so the same keys stay buffer motions while the editor is focused and become nav when a pane is — resolved by file order + the guard (no change to the editor motion bindings). - File tree: a flattened visible-index selection cursor in FileTreeController (j/k move, h collapse-or-out, l expand-or-into, o/enter open), with a focus ring + scroll-into-view. - Conversation: j/k line-scroll, ctrl+d/u half-page, gg top, G bottom — G re-arms follow-tail. Foundation for T-404/T-405/T-407, which build on the per-pane matcher and the editor.focused guard. Git panel + ticket board list nav deferred to a follow-up (the ticket says lists can trail). Tests: keymap resolution under both scopes, PaneKeyNav dispatch, the controller selection model, and end-to-end key-driven nav in both panes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 5 + .pql/changelog/tickets/2026-06.sql | 175 ++++++++++++++++++ CHANGELOG.md | 5 + assets/keymaps/vim.yaml | 37 ++++ lib/builtin/claude/src/conversation_view.dart | 42 ++++- lib/builtin/editor/src/editor_view.dart | 8 + .../files/src/file_tree_controller.dart | 114 ++++++++++++ lib/builtin/files/src/file_tree_view.dart | 124 ++++++++++--- lib/kernel/kernel.dart | 1 + lib/kernel/src/keymap/intents.dart | 67 +++++++ lib/kernel/src/keymap/pane_key_nav.dart | 122 ++++++++++++ .../claude/conversation_view_test.dart | 58 +++++- test/builtin/editor/vim_editor_test.dart | 7 + test/builtin/editor/vim_preset_test.dart | 45 ++++- .../files/file_tree_controller_test.dart | 93 ++++++++++ test/builtin/files/file_tree_nav_test.dart | 135 ++++++++++++++ test/kernel/src/keymap/pane_key_nav_test.dart | 81 ++++++++ 17 files changed, 1093 insertions(+), 26 deletions(-) create mode 100644 lib/kernel/src/keymap/pane_key_nav.dart create mode 100644 test/builtin/files/file_tree_nav_test.dart create mode 100644 test/kernel/src/keymap/pane_key_nav_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index a76844d2..4e325e2d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4775,3 +4775,8 @@ STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close th --- DECISION: no-active-buffer behavior (2026-06-13, user) --- RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 — they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', NULL, '2026-06-13 11:41:23', '2026-06-13 11:41:23', '2026-06-13 11:41:23', NULL, '526f24177bfa0f09ca59c571d9e84705', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'backlog', 'in_progress', NULL, '2026-06-13 11:42:26', '2026-06-13 11:42:26', '2026-06-13 11:42:26', NULL, 'a4b28821b462cd1fadab05c4e159b66b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'ready', 'in_progress', NULL, '2026-06-13 11:45:19', '2026-06-13 11:45:19', '2026-06-13 11:45:19', NULL, '7467127de4d8f29ed0e76b8a01489af9', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:21', '2026-06-13 11:47:21', '2026-06-13 11:47:21', NULL, 'dd46fa777ff5fbb42396fe4037288165', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:26', '2026-06-13 11:47:26', '2026-06-13 11:47:26', NULL, 'fc555a3966e167d5273a8c8338214070', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:31', '2026-06-13 11:47:31', '2026-06-13 11:47:31', NULL, '56abf6590754059d88570072056c5e31', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'in_progress', 'done', NULL, '2026-06-13 12:45:43', '2026-06-13 12:45:43', '2026-06-13 12:45:43', NULL, 'e540413389a0c213369d83a3a75f9706', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 33f0b8f8..eab5a28f 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -5161,3 +5161,178 @@ OPEN QUESTIONS: - File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view. - Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection. - Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 11:42:26', NULL, 'a1258e7d47ac99671a66d47eed661939', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings: + +TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay"). + +EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today. + +GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them. + +Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible. + +--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404–407) --- +SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w …, g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift — and it must be built ONCE, in one place, not three times. + +RECOMMENDED SEQUENCING: +1. T-406 (the structural child) FIRST — it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability. +2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path — land it anytime for immediate value across every preset. +3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists. +4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet — T-407 must add one). + +All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) — coordinate the shared-prefix matcher tests.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-13 11:45:19', NULL, '5518d537ba94da58e79fb00562bab03a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services: + +- `ctrl+w h` → command:panel.focus.left; `ctrl+w l` → command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` → command:dock.toggle — document the approximation in the YAML comment) +- `ctrl+w w` and `ctrl+w ctrl+w` → focus.nextPanel; `ctrl+w shift+w` → focus.previousPanel +- `ctrl+w o` → command:panel.focusMode (vim "only" — exact semantic match) +- `ctrl+w q` and `ctrl+w c` → command:editor.close + +Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset — bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord). + +Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes. + +--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) --- +SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands — but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent → Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings — it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part. + +ACCEPTANCE CRITERIA: +- vim.yaml gains ctrl+w bindings: `ctrl+w h`→panel.focus.left, `ctrl+w l`→panel.focus.right, `ctrl+w j`→dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`→focus.nextPanel, `ctrl+w shift+w`→focus.previousPanel, `ctrl+w o`→panel.focusMode, `ctrl+w q`/`ctrl+w c`→editor.close, all `when: vim.normal || vim.visual`. +- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused. +- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w. +- No resolution change under default/vscode/jetbrains — editor_presets_test.dart `ctrl+w → editor.close` (e.g. line 60) stays green. +- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases. +- make analyze + format + keymap suite pass; 95% coverage floor holds. + +FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey — single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords — decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart. + +DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own — non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface — coordinate ordering to avoid three matcher rewires. + +OPEN QUESTIONS: +- Where does the global multi-chord matcher live — a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift. +- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately — confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives. +- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists — intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.) +- Should the family also fire in vim.insert (it shouldn''t — ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-13 11:47:21', NULL, '0a109484fa4b7e20860d503cebb1c612', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks): + +1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists. + +2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals. + +Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged. + +--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) --- +SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap — only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle — so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`→workspace.tab.next and `g shift+t`→workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntent→commands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it — but only once a matcher runs on the focused surface. + +ACCEPTANCE CRITERIA: +- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab. +- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test. +- vim.yaml binds `g t`→workspace.tab.next, `g shift+t`→workspace.tab.previous, when: vim.normal. +- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either. +- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace). +- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope. +- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched. + +FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` — same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming). + +DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now — needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407. + +OPEN QUESTIONS: +- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout. +- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic. +- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset". +- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-13 11:47:26', NULL, 'b987c27388c5af5841dbf454e365de32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter: + +- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings). +- v1 grammar, one table, no parsing cleverness: + :w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e → quickOpen.open pre-seeded with (check QuickOpenIntent for a seed param; add one if absent), : → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), : → shake/flash + stay open. +- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists. +- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it). + +Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes. + +--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) --- +SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e →quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) : goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist. + +ACCEPTANCE CRITERIA: +- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn. +- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e opens quick-open seeded with , : jumps the active buffer to that line. +- : executes nothing and flashes/shakes + stays open (no silent command:foo dispatch). +- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path. +- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension). +- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it. +- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets. + +FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/. + +DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first. + +OPEN QUESTIONS: +- :q today→editor.close closes the whole split (arrangement.closeEditor), not the focused tab — acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users. +- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism — a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb — and where it lives (editor ext vs vim ext). +- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" — confirm the no-buffer behavior. +- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated? +- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer — add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer? + +--- DECISION: :q / ZZ close semantics (2026-06-12, user) --- +RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants). + +KEY MECHANISM (verified in code) — this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all: +- `:q` → EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) — the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested). +- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}. +- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 → lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab — no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only. + +THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options: + (a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close — no protocol change; keeps tab-visual-order knowledge in the view that owns it. + (b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q. +RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building. + +ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line): +- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active → closeEditor path) — no separate editor.close dispatch. +- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed. +- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path. +- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes — does NOT touch other panes (still an open question below). + +STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle — keep v1 editor-targeted.) + +--- DECISION: no-active-buffer behavior (2026-06-13, user) --- +RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 — they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:47:31', NULL, 'e8a308082017a6bdbb79aa0cff8c9c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys. + +Mechanism (follow the ActivateIntent pattern from default.yaml — intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion): + +1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents). +2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists — check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary — verify dispatch order and document it. +3. Panes opt in with Actions handlers: + - file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom) + - conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top + - ticket board + git panel lists: selection cursor + activate +4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later — the intents are preset-neutral; this ticket only wires vim. + +Scope guard: this is keyboard NAVIGATION only — no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket. + +Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode. + +--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) --- +SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only — it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, G→maxScrollExtent AND re-arm _atBottom follow-tail, gg→0). Lists (tickets/git) trail in a follow-up commit. Navigation only — no editing semantics outside the editor. + +THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability — land/decide this first. + +ACCEPTANCE CRITERIA: +- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id. +- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual. +- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible. +- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, gg→offset 0, G→_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms. +- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes. +- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged. +- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket. + +FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude. + +DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart — coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay). + +OPEN QUESTIONS: +- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag — VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard — (b) only works because each pane owns its handler. +- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view. +- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection. +- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 12:45:43', NULL, '5d7f59ef58611f7fe6c8a4cb3e755154', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 143ddaee..f09f8913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Vim normal-mode navigation works outside the editor.** Under the vim preset, + a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G, + and (tree) h/l/o — a selection cursor in the tree, scrolling in the + conversation. Each pane runs its own sequence matcher; an `editor.focused` + flag keeps these keys as buffer motions while the editor holds focus. (T-406) - **Claude Code Workflow runs surface in the conversation and sidebar.** A `Workflow` tool-use renders a dedicated run card — phase groups, per-agent rows with live spinner/check status, usage, and the script — driven by the diff --git a/assets/keymaps/vim.yaml b/assets/keymaps/vim.yaml index 13c9b842..a6adc410 100644 --- a/assets/keymaps/vim.yaml +++ b/assets/keymaps/vim.yaml @@ -63,6 +63,43 @@ bindings: - intent: text.scaleReset keys: [ctrl+0, meta+0] + # ---- Pane navigation (non-editor panes) ------------------------------ + # When a non-editor pane holds focus (file tree, conversation, lists), the + # same motion keys mean NAVIGATION, not buffer edits (T-406). The + # `!editor.focused` guard keeps these out of the editor's way; the editor + # publishes `editor.focused` while it has focus. These MUST precede the + # editor motions below — the resolver takes the first matching binding in + # file order, so with a pane focused (editor.focused false) nav wins, and + # with the editor focused the `!editor.focused` clause fails and the buffer + # motion below wins. Each pane runs its own SequenceMatcher (PaneKeyNav). + - intent: nav.down + keys: j + when: "vim.normal && !editor.focused" + - intent: nav.up + keys: k + when: "vim.normal && !editor.focused" + - intent: nav.pageDown + keys: ctrl+d + when: "vim.normal && !editor.focused" + - intent: nav.pageUp + keys: ctrl+u + when: "vim.normal && !editor.focused" + - intent: nav.top + keys: "g g" # gg + when: "vim.normal && !editor.focused" + - intent: nav.bottom + keys: shift+g # G + when: "vim.normal && !editor.focused" + - intent: nav.expandOrRight + keys: l + when: "vim.normal && !editor.focused" + - intent: nav.collapseOrLeft + keys: h + when: "vim.normal && !editor.focused" + - intent: nav.activate + keys: [o, enter] + when: "vim.normal && !editor.focused" + # ---- Mode transitions ------------------------------------------------ - intent: command:vim.mode.visual keys: v diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index df30bea9..a1e7e92c 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -24,6 +24,8 @@ import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:clide/kernel/src/facade.dart'; +import 'package:clide/kernel/src/keymap/intents.dart'; +import 'package:clide/kernel/src/keymap/pane_key_nav.dart'; import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; @@ -387,10 +389,48 @@ class _ConversationViewState extends State { return list; }, ); - return ColoredBox( + final body = ColoredBox( color: tokens.panelBackground, child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized, ); + // Vim nav scrolls the conversation while this region holds focus under the + // vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to + // the ends — G also re-arms follow-tail so new output keeps it pinned. + return PaneKeyNav(onNav: _onNav, child: body); + } + + /// One "line" of scroll for j/k — a few text rows' worth. + static const double _lineScroll = 48; + + void _onNav(NavIntent intent, int count) { + if (!_scroll.hasClients) return; + final p = _scroll.position; + final half = p.viewportDimension / 2; + switch (intent) { + case NavDownIntent(): + _scrollBy(_lineScroll * count); + case NavUpIntent(): + _scrollBy(-_lineScroll * count); + case NavPageDownIntent(): + _scrollBy(half); + case NavPageUpIntent(): + _scrollBy(-half); + case NavTopIntent(): + _scroll.jumpTo(0); + _atBottom = false; + case NavBottomIntent(): + _scroll.jumpTo(p.maxScrollExtent); + _atBottom = true; // re-arm follow-tail (T-297) + case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent(): + break; // a reader pane has no expand/activate semantics + } + } + + void _scrollBy(double delta) { + final p = _scroll.position; + final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent); + _scroll.jumpTo(target); + _atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon; } } diff --git a/lib/builtin/editor/src/editor_view.dart b/lib/builtin/editor/src/editor_view.dart index 6f73e22b..f1b95eb9 100644 --- a/lib/builtin/editor/src/editor_view.dart +++ b/lib/builtin/editor/src/editor_view.dart @@ -54,10 +54,16 @@ class _EditorViewState extends State { super.initState(); _text = SyntaxTextController(syntax: _syntax); _focus = FocusNode(); + _focus.addListener(_onFocusChanged); _text.addListener(_onTextChanged); _tabs.addListener(_onTabsChanged); } + /// Publish `editor.focused` so non-editor panes can guard their vim nav + /// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G + /// stay buffer motions; when a pane holds focus they become nav (T-406). + void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus); + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -75,12 +81,14 @@ class _EditorViewState extends State { void dispose() { _text.removeListener(_onTextChanged); _text.dispose(); + _focus.removeListener(_onFocusChanged); _focus.dispose(); _tabs.removeListener(_onTabsChanged); _tabs.dispose(); _controller?.removeListener(_onControllerChanged); _controller?.dispose(); _keymap?.removeListener(_onModeChanged); + _keymap?.clearScopeFlag('editor.focused'); super.dispose(); } diff --git a/lib/builtin/files/src/file_tree_controller.dart b/lib/builtin/files/src/file_tree_controller.dart index 7f7ec304..4c679618 100644 --- a/lib/builtin/files/src/file_tree_controller.dart +++ b/lib/builtin/files/src/file_tree_controller.dart @@ -9,11 +9,26 @@ library; import 'dart:async'; +import 'dart:io' show Platform; import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/foundation.dart'; +/// One row in the flattened, currently-visible tree (T-406). The visible set is +/// a pre-order walk of the root plus the children of every expanded directory — +/// the same order the tree renders — so a selection cursor can move over it with +/// j/k. +@immutable +class TreeNode { + const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth}); + + final String path; + final String name; + final bool isDirectory; + final int depth; +} + class FileTreeController extends ChangeNotifier { FileTreeController({required this.ipc, required this.events}) { _eventSub = events.on().listen(_onEvent); @@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier { final Map> _entries = {}; List? entriesFor(String path) => _entries[path]; + /// Display name of the workspace root row ('' path). + String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? ''; + + // -- Keyboard selection cursor (T-406) ------------------------------------- + + /// The path of the currently selected row, or null when nothing is selected. + /// '' is the workspace-root row. + String? _selectedPath; + String? get selectedPath => _selectedPath; + + /// The flattened, currently-visible rows in render order: the root, then the + /// children of every expanded directory, depth-first. + List visibleNodes() { + final out = []; + if (_rootPath == null) return out; + out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0)); + if (isExpanded('')) _appendChildren('', 1, out); + return out; + } + + void _appendChildren(String path, int depth, List out) { + final entries = _entries[path]; + if (entries == null) return; + for (final e in entries) { + out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth)); + if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out); + } + } + + TreeNode? _selectedNode([List? nodes]) { + final list = nodes ?? visibleNodes(); + for (final n in list) { + if (n.path == _selectedPath) return n; + } + return null; + } + + /// Move the selection cursor [delta] rows (negative = up), clamped to the + /// visible list. A first move with nothing selected lands on the first row + /// (down) or last row (up). + void moveSelection(int delta) { + final nodes = visibleNodes(); + if (nodes.isEmpty) return; + final cur = nodes.indexWhere((n) => n.path == _selectedPath); + final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1); + if (nodes[next].path == _selectedPath) return; + _selectedPath = nodes[next].path; + notifyListeners(); + } + + /// Select the first ([top]) or last visible row — vim gg / G. + void selectEdge({required bool top}) { + final nodes = visibleNodes(); + if (nodes.isEmpty) return; + final path = (top ? nodes.first : nodes.last).path; + if (path == _selectedPath) return; + _selectedPath = path; + notifyListeners(); + } + + /// Collapse the selected directory, or — if it's already collapsed (or a + /// file) — step the selection out to its parent row (vim `h`). + Future collapseOrOut() async { + final node = _selectedNode(); + if (node == null) return; + if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) { + await toggle(node.path); // collapse in place; selection stays on the dir + return; + } + if (node.path == '') return; // already at root + _selectedPath = _parentOf(node.path); + notifyListeners(); + } + + /// Expand the selected directory, or — if it's already expanded — step the + /// selection into its first child (vim `l`). A file is a no-op. + Future expandOrInto() async { + final node = _selectedNode(); + if (node == null || !node.isDirectory) return; + if (!_expanded.contains(node.path)) { + await toggle(node.path); // expand + return; + } + final children = _entries[node.path]; + if (children != null && children.isNotEmpty) { + _selectedPath = children.first.path; + notifyListeners(); + } + } + + /// Resolve the selected row to an action target for the view: a directory to + /// toggle, or a file path to open (vim `o` / `enter`). Returns null when + /// nothing is selected. + ({bool isDirectory, String path})? activateTarget() { + final node = _selectedNode(); + if (node == null) return null; + return (isDirectory: node.isDirectory, path: node.path); + } + List allLoadedEntries() { final out = []; for (final list in _entries.values) { diff --git a/lib/builtin/files/src/file_tree_view.dart b/lib/builtin/files/src/file_tree_view.dart index be30af16..da7f855c 100644 --- a/lib/builtin/files/src/file_tree_view.dart +++ b/lib/builtin/files/src/file_tree_view.dart @@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget { class _FileTreeViewState extends State { FileTreeController? _controller; String _filter = ''; + final ScrollController _scroll = ScrollController(); + + /// Key on the currently-selected row, so a keyboard move can scroll it into + /// view (T-406). + final GlobalKey _selectedKey = GlobalKey(); + + /// Half-page step for ctrl+d / ctrl+u over the flattened tree. + static const int _pageStep = 10; @override void didChangeDependencies() { @@ -39,9 +47,52 @@ class _FileTreeViewState extends State { @override void dispose() { _controller?.dispose(); + _scroll.dispose(); super.dispose(); } + void _onNav(NavIntent intent, int count, FileTreeController c) { + switch (intent) { + case NavDownIntent(): + c.moveSelection(count); + case NavUpIntent(): + c.moveSelection(-count); + case NavPageDownIntent(): + c.moveSelection(_pageStep); + case NavPageUpIntent(): + c.moveSelection(-_pageStep); + case NavTopIntent(): + c.selectEdge(top: true); + case NavBottomIntent(): + c.selectEdge(top: false); + case NavExpandOrRightIntent(): + unawaited(c.expandOrInto()); + case NavCollapseOrLeftIntent(): + unawaited(c.collapseOrOut()); + case NavActivateIntent(): + _activateSelected(c); + } + } + + void _activateSelected(FileTreeController c) { + final t = c.activateTarget(); + if (t == null) return; + if (t.isDirectory) { + unawaited(c.toggle(t.path)); + } else { + openWorkspaceFile(ClideKernel.of(context), t.path); + } + } + + /// Scroll the selected row into view after the frame it's laid out in. + void _ensureSelectedVisible() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _selectedKey.currentContext; + if (ctx == null) return; + Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80)); + }); + } + @override Widget build(BuildContext context) { final c = _controller; @@ -57,6 +108,23 @@ class _FileTreeViewState extends State { return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)); } final rootName = root.split(Platform.pathSeparator).last; + final selected = c.selectedPath; + if (_filter.isEmpty && selected != null) _ensureSelectedVisible(); + final scroller = SingleChildScrollView( + controller: _scroll, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_filter.isEmpty) ...[ + _DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey), + if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey), + ] else + ..._filteredEntries(c), + ], + ), + ); return Column( children: [ ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)), @@ -65,20 +133,10 @@ class _FileTreeViewState extends State { label: 'file tree — $rootName', container: true, explicitChildNodes: true, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (_filter.isEmpty) ...[ - _DirRow(name: rootName, path: '', controller: c, depth: 0), - if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1), - ] else - ..._filteredEntries(c), - ], - ), - ), + // Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this + // region holds focus under the vim preset (T-406). The filter + // box sits outside it, so typing a filter is never intercepted. + child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller, ), ), ], @@ -97,11 +155,13 @@ class _FileTreeViewState extends State { } class _Children extends StatelessWidget { - const _Children({required this.path, required this.controller, required this.depth}); + const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey}); final String path; final FileTreeController controller; final int depth; + final String? selectedPath; + final Key? selectedKey; @override Widget build(BuildContext context) { @@ -117,58 +177,67 @@ class _Children extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - _DirRow(name: e.name, path: e.path, controller: controller, depth: depth), - if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1), + _DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey), + if (controller.isExpanded(e.path)) + _Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey), ], ) else - _FileRow(name: e.name, path: e.path, depth: depth), + _FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey), ], ); } } class _DirRow extends StatelessWidget { - const _DirRow({required this.name, required this.path, required this.controller, required this.depth}); + const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey}); final String name; final String path; final FileTreeController controller; final int depth; + final String? selectedPath; + final Key? selectedKey; @override Widget build(BuildContext context) { final expanded = controller.isExpanded(path); final tokens = ClideTheme.of(context).surface; + final selected = path == selectedPath; return Semantics( button: true, label: '${expanded ? 'Collapse' : 'Expand'} $name', onTap: () => controller.toggle(path), child: _Row( + key: selected ? selectedKey : null, depth: depth, onTap: () => controller.toggle(path), leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground), label: name, rotateLeading: expanded, + selected: selected, ), ); } } class _FileRow extends StatelessWidget { - const _FileRow({required this.name, required this.path, required this.depth}); + const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey}); final String name; final String path; final int depth; + final String? selectedPath; + final Key? selectedKey; @override Widget build(BuildContext context) { + final selected = path == selectedPath; return Semantics( button: true, label: 'Open $name', onTap: () => _openFile(context, path), - child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name), + child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected), ); } @@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget { } class _Row extends StatelessWidget { - const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false}); + const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false}); final int depth; final VoidCallback onTap; @@ -188,6 +257,10 @@ class _Row extends StatelessWidget { final Widget? leading; final bool rotateLeading; + /// True when the keyboard selection cursor is on this row (T-406) — draws a + /// persistent highlight + accent ring, distinct from transient hover. + final bool selected; + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; @@ -195,7 +268,12 @@ class _Row extends StatelessWidget { return ClideTappable( onTap: onTap, builder: (context, hovered, _) => Container( - color: hovered ? tokens.sidebarItemHover : null, + decoration: selected + ? BoxDecoration( + color: tokens.sidebarItemHover, + border: Border.all(color: tokens.globalFocus, width: 1), + ) + : (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null), padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3), child: Row( children: [ diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 261cf017..9046379d 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -28,6 +28,7 @@ export 'src/keymap/key_chord.dart'; export 'src/keymap/keymap.dart'; export 'src/keymap/keymap_service.dart'; export 'src/keymap/modifier_tap.dart'; +export 'src/keymap/pane_key_nav.dart'; export 'src/keymap/sequence_matcher.dart'; export 'src/keymap/when_clause.dart'; export 'src/dialog.dart'; diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart index a5473a33..5c9396df 100644 --- a/lib/kernel/src/keymap/intents.dart +++ b/lib/kernel/src/keymap/intents.dart @@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent { const TextScaleResetIntent(); } +// -- Pane navigation (vim normal-mode motions outside the editor) ------------ + +/// Base for the preset-neutral navigation intents (T-406). A focused non-editor +/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and +/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds +/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys +/// to the same ids. Marker base so a pane's key handler can tell a nav motion +/// apart from any other fired intent. +sealed class NavIntent extends Intent { + const NavIntent(); +} + +/// Move the selection / scroll down one step (vim `j`). +class NavDownIntent extends NavIntent { + const NavDownIntent(); +} + +/// Move the selection / scroll up one step (vim `k`). +class NavUpIntent extends NavIntent { + const NavUpIntent(); +} + +/// Scroll down half a viewport (vim `ctrl+d`). +class NavPageDownIntent extends NavIntent { + const NavPageDownIntent(); +} + +/// Scroll up half a viewport (vim `ctrl+u`). +class NavPageUpIntent extends NavIntent { + const NavPageUpIntent(); +} + +/// Jump to the first item / top (vim `gg`). +class NavTopIntent extends NavIntent { + const NavTopIntent(); +} + +/// Jump to the last item / bottom (vim `G`). +class NavBottomIntent extends NavIntent { + const NavBottomIntent(); +} + +/// Expand the focused node, or step into it / move right (vim `l`). +class NavExpandOrRightIntent extends NavIntent { + const NavExpandOrRightIntent(); +} + +/// Collapse the focused node, or step out of it / move left (vim `h`). +class NavCollapseOrLeftIntent extends NavIntent { + const NavCollapseOrLeftIntent(); +} + +/// Activate the focused item — open the file, run the row (vim `o` / `enter`). +class NavActivateIntent extends NavIntent { + const NavActivateIntent(); +} + // -- Command bridge --------------------------------------------------------- /// Generic "invoke this CommandRegistry command id" intent. Used for @@ -136,6 +193,16 @@ final Map builtinIntents = { 'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(), 'findInFiles.open': () => const FindInFilesIntent(), + // Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc. + 'nav.down': () => const NavDownIntent(), + 'nav.up': () => const NavUpIntent(), + 'nav.pageDown': () => const NavPageDownIntent(), + 'nav.pageUp': () => const NavPageUpIntent(), + 'nav.top': () => const NavTopIntent(), + 'nav.bottom': () => const NavBottomIntent(), + 'nav.expandOrRight': () => const NavExpandOrRightIntent(), + 'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(), + 'nav.activate': () => const NavActivateIntent(), 'text.scaleIncrease': () => const TextScaleIncreaseIntent(), 'text.scaleDecrease': () => const TextScaleDecreaseIntent(), 'text.scaleReset': () => const TextScaleResetIntent(), diff --git a/lib/kernel/src/keymap/pane_key_nav.dart b/lib/kernel/src/keymap/pane_key_nav.dart new file mode 100644 index 00000000..65c34173 --- /dev/null +++ b/lib/kernel/src/keymap/pane_key_nav.dart @@ -0,0 +1,122 @@ +/// A reusable vim normal-mode navigation key handler for non-editor panes +/// (T-406). +/// +/// The passive global key path is single-chord only and can't run sequences or +/// consume events (D-82), so — exactly like the editor's command-mode handler — +/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a +/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file +/// tree, conversation, and lists share one implementation. +/// +/// While a `vim.normal` scope flag is set and this region holds focus, bare and +/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed +/// the matcher against the live keymap; a fired [NavIntent] is handed to +/// [onNav] with its repeat count. Everything else under `vim.normal` is +/// swallowed (vim normal mode is inert for unbound keys), except other-modifier +/// chords (palette, quick-open, …) which bubble to the global handler. Under a +/// non-vim preset or in insert mode the region is transparent — keys pass +/// straight through. +/// +/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key +/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav +/// intent here and to the editor motion in the editor — see vim.yaml. +library; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../facade.dart'; +import 'intents.dart'; +import 'key_chord.dart'; +import 'keymap.dart'; +import 'sequence_matcher.dart'; + +/// Signature for a fired navigation motion: the [intent] and its repeat +/// [count] (>= 1, from a leading digit prefix like `5j`). +typedef NavHandler = void Function(NavIntent intent, int count); + +class PaneKeyNav extends StatefulWidget { + const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true}); + + final Widget child; + + /// Called when a `nav.*` motion resolves while this region has focus. + final NavHandler onNav; + + /// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that + /// want to move focus here programmatically (a row tap, F6) pass their own. + final FocusNode? focusNode; + + final bool autofocus; + + /// Whether the region can take focus at all. False makes it a pure pass-through + /// (used when a pane temporarily routes keys elsewhere, e.g. a filter box). + final bool canRequestFocus; + + @override + State createState() => _PaneKeyNavState(); +} + +class _PaneKeyNavState extends State { + FocusNode? _ownNode; + SequenceMatcher? _matcher; + + FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav')); + + /// The half-page scroll chords are the only modified chords this handler + /// claims; every other modified chord bubbles to the global shortcut path. + static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD); + static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_matcher != null) return; + final kernel = ClideKernel.of(context); + _matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope); + } + + @override + void dispose() { + _ownNode?.dispose(); + super.dispose(); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored; + final kernel = ClideKernel.of(context); + // Only vim normal mode drives pane navigation. Insert/visual or a non-vim + // preset → transparent, keys pass through to whatever's below. + if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored; + + final hw = HardwareKeyboard.instance; + final chord = KeyChord.fromKeyEvent(event, hw); + if (chord == null) return KeyEventResult.ignored; + + // Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only + // modified chords we claim (half-page scroll). Any other modified chord is + // an app shortcut (palette, quick-open) — let it bubble to the global path. + final modified = chord.modifiers.any((m) => m != KeyModifier.shift); + if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored; + + final r = _matcher!.feed(chord); + switch (r.outcome) { + case SeqOutcome.fired: + // The vim preset also binds these keys to editor.vim.* motions; in a + // pane only nav.* applies. A non-nav fired intent (e.g. a stray + // editor.vim.* with no focus guard) is swallowed, never executed here. + if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count); + return KeyEventResult.handled; + case SeqOutcome.pending: + return KeyEventResult.handled; + case SeqOutcome.unmatched: + // Vim normal mode beeps on unbound keys — swallow so a bare key never + // leaks to text input or the global handler. + return KeyEventResult.handled; + } + } + + @override + Widget build(BuildContext context) { + return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child); + } +} diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index b10bc8d4..175c6db6 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -14,10 +14,11 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/transcript_publisher.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/workflow_run.dart'; +import 'package:clide/kernel/kernel.dart' show PaneKeyNav; import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey; +import 'package:flutter/widgets.dart' show Builder, Focus, Image, FileImage, MediaQuery, Scrollable, ScrollableState, ValueKey; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; @@ -199,6 +200,61 @@ void main() { expect(find.text('Waiting for Claude…'), findsOneWidget); }); + testWidgets('vim G / gg / j scroll the conversation under vim.normal (T-406)', (tester) async { + await tester.runAsync(() => f.services.keymap.setPreset('vim')); + f.services.keymap.setScopeFlag('vim.normal', true); + addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal')); + + // Enough prose to overflow the 700px viewport so there's room to scroll. + await pumpWith(tester, [for (var i = 0; i < 40; i++) AssistantTextMessage(uuid: 'a$i', timestamp: _t, isSidechain: false, text: 'line number $i')]); + + // Focus the pane's nav region (its own Focus is PaneKeyNav's outermost). + final node = tester.widget(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!; + node.requestFocus(); + await tester.pump(); + + final pos = tester.state(find.byType(Scrollable).first).position; + expect(pos.maxScrollExtent, greaterThan(0), reason: 'content must overflow to scroll'); + + // G → jump to the bottom. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(pos.pixels, pos.maxScrollExtent); + + // gg → jump to the top. + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.pump(); + expect(pos.pixels, 0); + + // j → down one line (48px); k → back up. + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + await tester.pump(); + expect(pos.pixels, 48); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); + await tester.pump(); + expect(pos.pixels, 0); + + // ctrl+d / ctrl+u → half a viewport down then back up. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.pump(); + expect(pos.pixels, greaterThan(0)); + await tester.sendKeyEvent(LogicalKeyboardKey.keyU); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(pos.pixels, 0); + + // h / l / o have no reader-pane semantics — they don't move the scroll. + await tester.sendKeyEvent(LogicalKeyboardKey.keyL); + await tester.sendKeyEvent(LogicalKeyboardKey.keyH); + await tester.sendKeyEvent(LogicalKeyboardKey.keyO); + await tester.pump(); + expect(pos.pixels, 0); + }); + testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async { var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words'); run = run.foldEvent({ diff --git a/test/builtin/editor/vim_editor_test.dart b/test/builtin/editor/vim_editor_test.dart index f68fdce3..6ef0697f 100644 --- a/test/builtin/editor/vim_editor_test.dart +++ b/test/builtin/editor/vim_editor_test.dart @@ -56,6 +56,13 @@ void main() { await tester.pump(); } + testWidgets('focusing the editor publishes editor.focused (T-406)', (tester) async { + stubOneBuffer('hello'); + expect(f.services.keymap.scope['editor.focused'], isNot(true)); + await pumpEditor(tester); // taps into the editor → focus + expect(f.services.keymap.scope['editor.focused'], isTrue, reason: 'pane nav guards on !editor.focused'); + }); + testWidgets('normal-mode x deletes the char under the caret', (tester) async { String? sentText; f.ipc.stub('editor.set-content', (a) async { diff --git a/test/builtin/editor/vim_preset_test.dart b/test/builtin/editor/vim_preset_test.dart index 5683bca3..acb35686 100644 --- a/test/builtin/editor/vim_preset_test.dart +++ b/test/builtin/editor/vim_preset_test.dart @@ -22,9 +22,13 @@ void main() { km = Keymap([KeymapLayer.fromYaml(src)]); }); - const normal = {'vim.normal': true}; + // Editor-focused normal mode: j/k/h/l/gg/G/o are buffer motions here because + // the `editor.focused` flag suppresses the pane-nav bindings (T-406). + const normal = {'vim.normal': true, 'editor.focused': true}; const insert = {'vim.insert': true}; const visual = {'vim.visual': true}; + // A non-editor pane focused under vim normal mode: the same keys are nav.*. + const paneNormal = {'vim.normal': true}; Intent? resolve(String chord, Map scope) => km.resolve(KeyChord.parse(chord), scope); @@ -83,4 +87,43 @@ void main() { expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending); expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart'); }); + + group('pane navigation (T-406)', () { + test('motion keys resolve to nav.* when a non-editor pane is focused', () { + expect(resolve('j', paneNormal), isA()); + expect(resolve('k', paneNormal), isA()); + expect(resolve('h', paneNormal), isA()); + expect(resolve('l', paneNormal), isA()); + expect(resolve('ctrl+d', paneNormal), isA()); + expect(resolve('ctrl+u', paneNormal), isA()); + expect(resolve('shift+g', paneNormal), isA()); + expect(resolve('o', paneNormal), isA()); + expect(resolve('enter', paneNormal), isA()); + }); + + test('the editor.focused guard hands the same keys to the editor', () { + // With the editor focused, nav.* is suppressed and the buffer motions win. + expect(_cmd(resolve('j', normal)), 'editor.vim.down'); + expect(_cmd(resolve('h', normal)), 'editor.vim.left'); + expect(_cmd(resolve('l', normal)), 'editor.vim.right'); + expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd'); + expect(_cmd(resolve('o', normal)), 'editor.vim.openBelow'); + }); + + test('gg resolves to nav.top in a pane, docStart in the editor', () { + final pane = SequenceMatcher(keymap: () => km, context: () => paneNormal); + pane.feed(KeyChord.parse('g')); + expect(pane.feed(KeyChord.parse('g')).intent, isA()); + + final editor = SequenceMatcher(keymap: () => km, context: () => normal); + editor.feed(KeyChord.parse('g')); + expect(_cmd(editor.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart'); + }); + + test('pane nav is normal-mode only — visual mode keeps the editor motion', () { + // nav.* is guarded `vim.normal && !editor.focused`; visual mode has no + // vim.normal flag, so j stays the editor motion even without editor.focused. + expect(_cmd(resolve('j', visual)), 'editor.vim.down'); + }); + }); } diff --git a/test/builtin/files/file_tree_controller_test.dart b/test/builtin/files/file_tree_controller_test.dart index da0569fa..50bb79fc 100644 --- a/test/builtin/files/file_tree_controller_test.dart +++ b/test/builtin/files/file_tree_controller_test.dart @@ -382,6 +382,99 @@ void main() { }); }); + group('FileTreeController — keyboard selection (T-406)', () { + // Tree: '' (root) → [lib/ (→ app.dart), main.dart] + Future tree({bool expandLib = false}) async { + f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'})); + f.ipc.stub('files.watch', (_) async => _ok(const {})); + f.ipc.stub('files.ls', (args) async { + final path = args['path'] as String? ?? ''; + if (path == '') { + return _ok({ + 'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'main.dart', path: 'main.dart')], + }); + } + if (path == 'lib') { + return _ok({ + 'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')], + }); + } + return _ok({'entries': []}); + }); + final c = makeCtrl(); + await c.load(); + if (expandLib) await c.toggle('lib'); + return c; + } + + test('visibleNodes flattens the root + expanded children in render order', () async { + final c = await tree(expandLib: true); + expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'lib/app.dart', 'main.dart']); + expect(c.visibleNodes().map((n) => n.depth), [0, 1, 2, 1]); + }); + + test('a collapsed directory hides its children from the visible list', () async { + final c = await tree(); + expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'main.dart']); + }); + + test('moveSelection walks the visible list and clamps at the ends', () async { + final c = await tree(expandLib: true); + expect(c.selectedPath, isNull); + c.moveSelection(1); + expect(c.selectedPath, ''); // first move lands on the root + c.moveSelection(1); + expect(c.selectedPath, 'lib'); + c.moveSelection(2); + expect(c.selectedPath, 'main.dart'); // lib/app.dart skipped over by +2 + c.moveSelection(5); // clamp at the bottom + expect(c.selectedPath, 'main.dart'); + c.moveSelection(-100); // clamp at the top + expect(c.selectedPath, ''); + }); + + test('selectEdge jumps to the first / last visible row (gg / G)', () async { + final c = await tree(expandLib: true); + c.selectEdge(top: false); + expect(c.selectedPath, 'main.dart'); + c.selectEdge(top: true); + expect(c.selectedPath, ''); + }); + + test('expandOrInto expands a collapsed dir, then steps into its first child', () async { + final c = await tree(); + c.moveSelection(1); // root + c.moveSelection(1); // lib (collapsed) + expect(c.isExpanded('lib'), isFalse); + await c.expandOrInto(); // expands + expect(c.isExpanded('lib'), isTrue); + expect(c.selectedPath, 'lib'); // selection stays on the dir + await c.expandOrInto(); // steps into first child + expect(c.selectedPath, 'lib/app.dart'); + }); + + test('collapseOrOut collapses an expanded dir, then steps out to the parent', () async { + final c = await tree(expandLib: true); + c.selectEdge(top: true); + c.moveSelection(2); // lib/app.dart + expect(c.selectedPath, 'lib/app.dart'); + await c.collapseOrOut(); // a file → step to parent + expect(c.selectedPath, 'lib'); + await c.collapseOrOut(); // an expanded dir → collapse in place + expect(c.isExpanded('lib'), isFalse); + expect(c.selectedPath, 'lib'); + }); + + test('activateTarget reports the selected row as dir-or-file for the view', () async { + final c = await tree(expandLib: true); + c.selectEdge(top: true); + c.moveSelection(1); // lib + expect(c.activateTarget(), (isDirectory: true, path: 'lib')); + c.moveSelection(2); // main.dart + expect(c.activateTarget(), (isDirectory: false, path: 'main.dart')); + }); + }); + group('FileTreeController — dispose()', () { test('dispose cancels event subscription without error', () async { f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'})); diff --git a/test/builtin/files/file_tree_nav_test.dart b/test/builtin/files/file_tree_nav_test.dart new file mode 100644 index 00000000..d8980beb --- /dev/null +++ b/test/builtin/files/file_tree_nav_test.dart @@ -0,0 +1,135 @@ +/// Widget tests for keyboard navigation in the file tree (T-406): under the vim +/// preset a focused tree moves a selection cursor with j/k, expands with l, and +/// opens the selected file with o/enter — driving the FileTreeController through +/// PaneKeyNav. +library; + +import 'package:clide/builtin/files/src/file_tree_view.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +IpcResponse _ok(Map data) => IpcResponse.ok(id: '', data: data); +Map _entry(String name, String path, {bool dir = false}) => { + 'name': name, + 'path': path, + 'isDirectory': dir, + 'isSymlink': false, + 'sizeBytes': 0, + 'modifiedMs': 0, +}; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + // Tree: /repo → [lib/ (→ app.dart), main.dart]. + void stubTree() { + f.ipc.stub('files.root', (_) async => _ok({'path': '/repo'})); + f.ipc.stub('files.watch', (_) async => _ok(const {})); + f.ipc.stub('files.ls', (args) async { + final path = args['path'] as String? ?? ''; + if (path == '') { + return _ok({ + 'entries': [_entry('lib', 'lib', dir: true), _entry('main.dart', 'main.dart')], + }); + } + if (path == 'lib') { + return _ok({ + 'entries': [_entry('app.dart', 'lib/app.dart')], + }); + } + return _ok({'entries': []}); + }); + } + + Future mountFocused(WidgetTester tester) async { + await tester.runAsync(() => f.services.keymap.setPreset('vim')); + f.services.keymap.setScopeFlag('vim.normal', true); + addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal')); + await tester.pumpWidget(harness(f, const FileTreeView())); + await pumpAsync(tester); + final node = tester.widget(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!; + node.requestFocus(); + await tester.pump(); + } + + testWidgets('j moves the selection and o opens the selected file (T-406)', (tester) async { + stubTree(); + final opened = []; + f.ipc.stub('editor.open', (args) async { + opened.add(args['path'] as String? ?? ''); + return _ok(const {}); + }); + await mountFocused(tester); + + // visible: '' (root), 'lib', 'main.dart'. j×3 lands on main.dart. + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyO); + await tester.pump(); + await pumpAsync(tester); + + expect(opened, ['main.dart']); + }); + + testWidgets('l expands the selected directory, h collapses it (T-406)', (tester) async { + stubTree(); + await mountFocused(tester); + + expect(find.text('app.dart'), findsNothing); // lib collapsed + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // root + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // lib + await tester.sendKeyEvent(LogicalKeyboardKey.keyL); // expand + await tester.pump(); + await pumpAsync(tester); + expect(find.text('app.dart'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyH); // collapse lib + await tester.pump(); + await pumpAsync(tester); + expect(find.text('app.dart'), findsNothing); + }); + + testWidgets('G/gg/k and ctrl+d/u move the cursor; o on a dir toggles it (T-406)', (tester) async { + stubTree(); + await mountFocused(tester); + + // G → last visible row (main.dart), o → main.dart is a file → opens it. + final opened = []; + f.ipc.stub('editor.open', (args) async { + opened.add(args['path'] as String? ?? ''); + return _ok(const {}); + }); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); // G → bottom + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); // up → lib + await tester.pump(); + // o on the 'lib' directory toggles (expands) it rather than opening a file. + await tester.sendKeyEvent(LogicalKeyboardKey.keyO); + await tester.pump(); + await pumpAsync(tester); + expect(find.text('app.dart'), findsOneWidget); // lib expanded, no file opened + expect(opened, isEmpty); + + // gg → top, then ctrl+d / ctrl+u exercise the half-page paths. + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.sendKeyEvent(LogicalKeyboardKey.keyU); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + // No crash, selection stayed in bounds — the dispatch paths ran. + expect(opened, isEmpty); + }); +} diff --git a/test/kernel/src/keymap/pane_key_nav_test.dart b/test/kernel/src/keymap/pane_key_nav_test.dart new file mode 100644 index 00000000..1e5193ea --- /dev/null +++ b/test/kernel/src/keymap/pane_key_nav_test.dart @@ -0,0 +1,81 @@ +/// Widget tests for PaneKeyNav (T-406): the per-pane vim-normal key handler +/// that runs its own SequenceMatcher and dispatches nav.* intents — proven +/// end-to-end against the real vim preset and scope flags. +library; + +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../helpers/kernel_fixture.dart'; +import '../../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Future> pump(WidgetTester tester, {required Map scope}) async { + // setPreset does real asset + keybindings-file I/O; run it outside the + // fake-async zone or the testWidgets body hangs (the T-122 lesson). + await tester.runAsync(() => f.services.keymap.setPreset('vim')); + for (final e in scope.entries) { + f.services.keymap.setScopeFlag(e.key, e.value); + } + final got = []; + final node = FocusNode(); + addTearDown(node.dispose); + await tester.pumpWidget( + harness(f, PaneKeyNav(focusNode: node, autofocus: true, onNav: (i, _) => got.add(i), child: const SizedBox(width: 100, height: 100))), + ); + node.requestFocus(); + await tester.pump(); + return got; + } + + testWidgets('bare motions dispatch nav.* under vim.normal (pane focused)', (tester) async { + final got = await pump(tester, scope: {'vim.normal': true}); + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); + await tester.sendKeyEvent(LogicalKeyboardKey.keyH); + await tester.sendKeyEvent(LogicalKeyboardKey.keyL); + expect(got, [isA(), isA(), isA(), isA()]); + }); + + testWidgets('gg sequence resolves to nav.top', (tester) async { + final got = await pump(tester, scope: {'vim.normal': true}); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + expect(got, [isA()]); + }); + + testWidgets('ctrl+d / ctrl+u are claimed as half-page nav', (tester) async { + final got = await pump(tester, scope: {'vim.normal': true}); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.sendKeyEvent(LogicalKeyboardKey.keyU); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(got, [isA(), isA()]); + }); + + testWidgets('the editor.focused guard suppresses nav (keys go to the editor)', (tester) async { + final got = await pump(tester, scope: {'vim.normal': true, 'editor.focused': true}); + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); + // j/k now resolve to editor.vim.* — not NavIntents — so onNav never fires. + expect(got, isEmpty); + }); + + testWidgets('keys pass through outside vim normal mode', (tester) async { + final got = await pump(tester, scope: {'vim.insert': true}); + await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); + expect(got, isEmpty); + }); + + testWidgets('an unbound bare key is swallowed without dispatching nav', (tester) async { + final got = await pump(tester, scope: {'vim.normal': true}); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + expect(got, isEmpty); + }); +}