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 + InvokeCommandIntentcommands.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-404407) ---
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);
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Vim `gt` / `gT` tab motions.** Under the Vim preset, `gt`/`gT` cycle the
workspace tab strip (the same `workspace.tab.*` commands as `ctrl+pagedown`/
`ctrl+pageup`), resolved by the focused editor or pane and sharing the `g`
prefix with `gg`. Completes the T-403 cross-pane vim layer. (T-405)
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
+13
View File
@@ -133,6 +133,19 @@ bindings:
keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual"
# ---- Tab motions (T-405) ---------------------------------------------
# gt / gT cycle the workspace tab strip via the preset-neutral
# workspace.tab.* commands (also on ctrl+pagedown/up everywhere). Bare-`g`
# sequences are editor/pane-local — the focused editor's matcher or a pane's
# PaneKeyNav resolves them and runs the command; they share the `g` prefix
# with `g g` (docStart / nav.top), distinguished by the final chord.
- intent: command:workspace.tab.next
keys: g t # gt
when: vim.normal
- intent: command:workspace.tab.previous
keys: g shift+t # gT
when: vim.normal
# ---- Ex command-line (T-407) -----------------------------------------
# `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e <path>` `:N`);
# `ZZ` runs `:wq` directly. Both are typed app intents the editor's command
+13 -6
View File
@@ -21,6 +21,8 @@
/// intent here and to the editor motion in the editor — see vim.yaml.
library;
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -101,15 +103,20 @@ class _PaneKeyNavState extends State<PaneKeyNav> {
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, and editor.vim.* (an InvokeCommandIntent) is
// swallowed — never run buffer edits from a pane. A typed *app* intent
// (e.g. the ex-line `:` / ZZ) bubbles to the app-root Actions for its
// global handler (T-407).
// The vim preset binds these keys to several intent kinds. In a pane:
// - nav.* drives the pane (onNav);
// - editor.vim.* buffer edits are swallowed — never edit from a pane;
// - other commands (workspace.tab.* gt/gT, panel.*) execute (T-405);
// - a typed app intent (ex-line `:` / ZZ) bubbles to the app-root
// Actions for its global handler (T-407).
final fired = r.intent;
if (fired is NavIntent) {
widget.onNav(fired, r.count);
} else if (fired != null && fired is! InvokeCommandIntent) {
} else if (fired is InvokeCommandIntent) {
if (!fired.commandId.startsWith('editor.vim.')) {
unawaited(kernel.commands.execute(fired.commandId));
}
} else if (fired != null) {
Actions.maybeInvoke(context, fired);
}
return KeyEventResult.handled;
@@ -79,6 +79,27 @@ void main() {
});
});
group('vim preset tab motions (T-405)', () {
bool seqEq(List<KeyChord> seq, List<String> chords) {
if (seq.length != chords.length) return false;
for (var i = 0; i < chords.length; i++) {
if (seq[i] != KeyChord.parse(chords[i])) return false;
}
return true;
}
bool hasSeq(KeymapService svc, List<String> chords, String cmd) =>
svc.keymap!.effectiveBindings.any((b) => seqEq(b.sequence, chords) && isCommand(b.intent, cmd));
test('gt / gT bind workspace tab cycling, sharing the g prefix with gg', () async {
final svc = await activate('vim');
expect(hasSeq(svc, ['g', 't'], 'workspace.tab.next'), isTrue, reason: 'gt → workspace.tab.next');
expect(hasSeq(svc, ['g', 'shift+t'], 'workspace.tab.previous'), isTrue, reason: 'gT → workspace.tab.previous');
// The shared `g` prefix must not break gg (docStart) — different final.
expect(hasSeq(svc, ['g', 'g'], 'editor.vim.docStart'), isTrue, reason: 'gg → docStart intact');
});
});
group('jetbrains preset (T-66)', () {
test('setPreset activates it and the representative subset resolves', () async {
final svc = await activate('jetbrains');
@@ -3,6 +3,8 @@
/// end-to-end against the real vim preset and scope flags.
library;
import 'package:clide/clide.dart';
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -67,6 +69,47 @@ void main() {
expect(got, isEmpty);
});
testWidgets('gt executes a non-editor command (workspace.tab.next) from a focused pane (T-405)', (tester) async {
var ran = 0;
f.services.commands.register(
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyT);
await tester.pump();
expect(ran, 1);
});
testWidgets('editor.vim.* buffer edits never execute from a pane (T-405 guard)', (tester) async {
var ran = 0;
// If the pane wrongly ran editor.vim.*, this would fire on `d d`.
f.services.commands.register(
CommandContribution(
id: 'editor.vim.deleteLine',
command: 'editor.vim.deleteLine',
title: 'vim: delete line',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.pump();
expect(ran, 0);
});
testWidgets('keys pass through outside vim normal mode', (tester) async {
final got = await pump(tester, scope: {'vim.insert': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);