From 2e4f87455aa7aad90af60068969eafc54104797b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 16 Jun 2026 12:08:07 +0200 Subject: [PATCH] feat(vim): gt / gT cycle workspace tabs (T-405 part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 54 +++++++++++++++++++ CHANGELOG.md | 5 ++ assets/keymaps/vim.yaml | 13 +++++ lib/kernel/src/keymap/pane_key_nav.dart | 19 ++++--- .../src/keymap/editor_presets_test.dart | 21 ++++++++ test/kernel/src/keymap/pane_key_nav_test.dart | 43 +++++++++++++++ 7 files changed, 151 insertions(+), 6 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 682f0ae8..14e1c07a 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 70f1c5af..ce843612 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a90902..8cef3549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/assets/keymaps/vim.yaml b/assets/keymaps/vim.yaml index 9c610afe..e6764382 100644 --- a/assets/keymaps/vim.yaml +++ b/assets/keymaps/vim.yaml @@ -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 ` `:N`); # `ZZ` runs `:wq` directly. Both are typed app intents the editor's command diff --git a/lib/kernel/src/keymap/pane_key_nav.dart b/lib/kernel/src/keymap/pane_key_nav.dart index 73454d38..51c9b00e 100644 --- a/lib/kernel/src/keymap/pane_key_nav.dart +++ b/lib/kernel/src/keymap/pane_key_nav.dart @@ -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 { 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; diff --git a/test/kernel/src/keymap/editor_presets_test.dart b/test/kernel/src/keymap/editor_presets_test.dart index 1c795f36..77b6944f 100644 --- a/test/kernel/src/keymap/editor_presets_test.dart +++ b/test/kernel/src/keymap/editor_presets_test.dart @@ -79,6 +79,27 @@ void main() { }); }); + group('vim preset tab motions (T-405)', () { + bool seqEq(List seq, List 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 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'); diff --git a/test/kernel/src/keymap/pane_key_nav_test.dart b/test/kernel/src/keymap/pane_key_nav_test.dart index 1e5193ea..4630f86b 100644 --- a/test/kernel/src/keymap/pane_key_nav_test.dart +++ b/test/kernel/src/keymap/pane_key_nav_test.dart @@ -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);