feat(vim): gt / gT cycle workspace tabs (T-405 part 2)

Closes out the T-403 cross-pane vim layer. gt/gT bind to the existing
workspace.tab.next/previous commands (also on ctrl+pagedown/up for every
preset), resolved by the focused editor's matcher or a pane's PaneKeyNav —
bare-g sequences stay editor/pane-local (T-404's global matcher only engages
on modified-chord prefixes), so no global-matcher surgery.

- vim.yaml: g t -> command:workspace.tab.next, g shift+t -> .previous
  (vim.normal); shares the `g` prefix with `g g` (docStart / nav.top),
  distinguished by the final chord.
- PaneKeyNav now EXECUTES non-editor.vim.* command intents (e.g.
  workspace.tab.*) instead of swallowing all command intents, so gt/gT work
  from a focused pane; editor.vim.* buffer edits stay blocked in panes.

Tests: vim-preset resolution (gt/gT bind, gg intact), PaneKeyNav executes
the command from a pane, and editor.vim.* is never run from a pane. make
test green; analyze + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 12:08:07 +02:00
co-authored by Claude Opus 4.8
parent 3a59d5be8a
commit 2e4f87455a
7 changed files with 151 additions and 6 deletions
@@ -5752,3 +5752,5 @@ NOT yet exercised in the running GUI — recommend a quick live confirm (vim pre
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', NULL, '2026-06-16 09:42:34', '2026-06-16 09:42:34', '2026-06-16 09:42:34', NULL, 'c53afd4a1354012e36081b84dab66bfd', 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', 'ready', 'review', NULL, '2026-06-16 09:42:39', '2026-06-16 09:42:39', '2026-06-16 09:42:39', NULL, 'e2cc00b9f1b80080e17e0213079400f1', 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', 'review', 'done', NULL, '2026-06-16 09:56:18', '2026-06-16 09:56:18', '2026-06-16 09:56:18', NULL, 'e0ea178ba163bf3395c21793c8cf9a14', 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', 'ready', 'done', NULL, '2026-06-16 10:07:56', '2026-06-16 10:07:56', '2026-06-16 10:07:56', NULL, '4b0584f96faa90f0a715dde390c64008', 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', 'in_progress', 'done', NULL, '2026-06-16 10:08:00', '2026-06-16 10:08:00', '2026-06-16 10:08:00', NULL, 'a7ee8d073cb004d8f3c53a7128cc3aef', 2) ON CONFLICT(hash) DO NOTHING;
+54
View File
@@ -6762,3 +6762,57 @@ Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'done', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:56:18', NULL, 'aec7002f9cec6777eafbfdf163d1d4c3', 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.)
--- PROGRESS (2026-06-13) ---
PART 1 DONE (commit on main): workspace.tab.next / workspace.tab.previous cycle commands with wraparound + ctrl+pagedown/ctrl+pageup defaultBindings across every preset. Tests in test/builtin/default_layout/widget_test.dart.
PART 2 (gt/gT) STILL OPEN — and harder than the coordination note assumed. T-404 landed a global multi-chord matcher (root_shell), BUT it deliberately only STARTS a sequence on a MODIFIED chord (ctrl+w). Bare-key prefixes (g) are left editor/pane-local on purpose — otherwise the global matcher would steal `g` before the editor sees it, breaking gg/dd. So gt/gT (bare g) CANNOT just ride the global matcher. Options for part 2: (a) the editor/pane matchers grow gt/gT and dispatch command:workspace.tab.* (but then gt only works when the editor/a pane is focused, not globally); (b) a focus-agnostic bare-g disambiguation (g g = local docStart vs g t = global tab) — needs the global matcher to tentatively grab bare g AND coordinate with the editor''s matcher, which is the exact conflict T-404 avoided. Decide before building. The ctrl+pagedown/up commands already give every preset tab-cycling; gt/gT is a vim-affordance nicety on top.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-16 10:07:56', NULL, 'e0d10776a3893b8bc3cf5a8e13a2976e', 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.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-16 10:08:00', NULL, '90921bced174269b827a991909e792bd', 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);