diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 76626fe0..4b0d039f 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -487,3 +487,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, **Related:** epic T-65 (Vim preset), T-207 (Vim mode service owns Esc -> normal), T-205 / T-117 (key-sequence resolution + when-clauses). **Files:** `assets/keymaps/default.yaml`, `assets/keymaps/vim.yaml`, `lib/builtin/editor/src/editor_view.dart`, `lib/kernel/src/keymap/`.', NULL, '2026-06-06 14:01:03', '2026-06-06 14:01:03', '2026-06-06 14:01:03', NULL, '6cd03ce2694808401bc66fb0c9267284', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-257', 'status', 'backlog', 'in_progress', NULL, '2026-06-06 18:44:58', '2026-06-06 18:44:58', '2026-06-06 18:44:58', NULL, '00d866fbb270421aa9e7103ac2a9b720', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 61b760cf..db38673e 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1020,3 +1020,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, **Related:** epic T-65 (Vim preset), T-207 (Vim mode service owns Esc -> normal), T-205 / T-117 (key-sequence resolution + when-clauses). **Files:** `assets/keymaps/default.yaml`, `assets/keymaps/vim.yaml`, `lib/builtin/editor/src/editor_view.dart`, `lib/kernel/src/keymap/`.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-06 14:01:03', '2026-06-06 14:01:03', NULL, '5caecbc30404184d0b16182d6d3c2a15', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-257', 'bug', NULL, 'Esc closes the file/pane instead of returning to Vim normal mode', NULL, 'in_progress', 'high', NULL, NULL, NULL, '2026-06-06 14:01:03', '2026-06-06 18:44:58', NULL, '2b855c3601a5c853abbafef6d12391d1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 0bf94a9a..645d5cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- In Vim mode, Esc in insert/visual mode returns to normal mode instead of + closing the editor. The global "exit focus / close editor" Esc binding now + stands down while Vim is in insert or visual mode. (T-257) - The permission-mode badge and Ctrl/Cmd+M now visibly cycle the mode in the status line. The mode was changed on the session but never reflected back, so both looked dead. (T-250) diff --git a/lib/builtin/default_layout/src/extension.dart b/lib/builtin/default_layout/src/extension.dart index 7b9abe2a..9f281942 100644 --- a/lib/builtin/default_layout/src/extension.dart +++ b/lib/builtin/default_layout/src/extension.dart @@ -79,6 +79,10 @@ class DefaultLayoutExtension extends ClideExtension { command: 'panel.focusMode.exit', title: 'Exit Focus Mode', defaultBinding: 'escape', + // Stand down in Vim insert/visual mode so Esc returns to normal + // mode instead of closing the editor (T-257). Symmetric with + // vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual). + bindingWhen: '!vim.insert && !vim.visual', run: _exitFocusMode, ), // Editor split (D-049, D-054) diff --git a/lib/extension/src/contribution.dart b/lib/extension/src/contribution.dart index af5d8305..98afd7c9 100644 --- a/lib/extension/src/contribution.dart +++ b/lib/extension/src/contribution.dart @@ -107,11 +107,18 @@ class CommandContribution extends ContributionPoint { required this.run, this.title, this.defaultBinding, + this.bindingWhen, }); final String command; // e.g. "git.commit" final String? title; // "Git: Commit staged" final String? defaultBinding; // e.g. "ctrl+shift+g" + + /// Optional when-clause guarding [defaultBinding] (same grammar as keymap + /// YAML `when:`). Lets a global binding yield to a higher-context one — e.g. + /// `panel.focusMode.exit`'s `escape` stands down in Vim insert/visual mode so + /// the preset's `vim.mode.normal` wins (T-257). Null → binding always active. + final String? bindingWhen; final Future Function(List args) run; } diff --git a/lib/kernel/src/extensions_manager.dart b/lib/kernel/src/extensions_manager.dart index cc808398..339e8a7e 100644 --- a/lib/kernel/src/extensions_manager.dart +++ b/lib/kernel/src/extensions_manager.dart @@ -205,7 +205,7 @@ class ExtensionManager extends ChangeNotifier { // until all callers migrate; the keymap layer is the // canonical home for chord → command bindings (T-117). keybindings.bind(Keybinding.parse(binding), cmd.command); - keymap.registerCommandBinding(binding, cmd.command); + keymap.registerCommandBinding(binding, cmd.command, when: cmd.bindingWhen); } case TrayItemContribution t: tray.add(t); diff --git a/test/builtin/default_layout/focus_exit_vim_test.dart b/test/builtin/default_layout/focus_exit_vim_test.dart new file mode 100644 index 00000000..3142b6d4 --- /dev/null +++ b/test/builtin/default_layout/focus_exit_vim_test.dart @@ -0,0 +1,70 @@ +/// T-257: `panel.focusMode.exit` is bound to Escape in the contributions +/// layer, which outranks the active preset. Without a guard it swallowed Esc +/// in Vim insert mode and closed the editor instead of letting the vim preset's +/// `vim.mode.normal` (escape when vim.insert||vim.visual) return to normal mode. +/// The contribution now carries `bindingWhen: '!vim.insert && !vim.visual'`. +library; + +import 'dart:io'; + +import 'package:clide/builtin/default_layout/default_layout.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/src/keymap/intents.dart'; +import 'package:clide/kernel/src/keymap/key_chord.dart'; +import 'package:clide/kernel/src/keymap/keymap.dart'; +import 'package:clide/kernel/src/keymap/when_clause.dart'; +import 'package:flutter/widgets.dart' show Intent; +import 'package:flutter_test/flutter_test.dart'; + +String? _cmd(Intent? i) => i is InvokeCommandIntent ? i.commandId : null; + +void main() { + late CommandContribution exitCmd; + + setUp(() { + exitCmd = DefaultLayoutExtension().contributions.whereType().firstWhere((c) => c.command == 'panel.focusMode.exit'); + }); + + test('focusMode.exit escape binding is guarded against vim insert/visual', () { + expect(exitCmd.defaultBinding, 'escape'); + expect(exitCmd.bindingWhen, '!vim.insert && !vim.visual'); + }); + + group('Esc resolution with the guarded binding over the vim preset', () { + late Keymap km; + + setUp(() { + // Real layering: vim.yaml preset (low) under a contributions layer + // carrying the extension's actual focusMode.exit escape binding. + final preset = KeymapLayer.fromYaml(File('assets/keymaps/vim.yaml').readAsStringSync()); + final contributions = KeymapLayer(name: 'contributions', bindings: [ + KeymapBinding.chord( + KeyChord.parse(exitCmd.defaultBinding!), + intent: InvokeCommandIntent(exitCmd.command), + when: WhenExpr.tryParse(exitCmd.bindingWhen), + ), + ]); + // Keymap flattens layers in reverse, so contributions outrank preset — + // matching KeymapService._rebuildActive's ordering. + km = Keymap([preset, contributions]); + }); + + Intent? esc(Map scope) => km.resolve(KeyChord.parse('escape'), scope); + + test('insert mode → vim.mode.normal (not panel.focusMode.exit)', () { + expect(_cmd(esc(const {'vim.insert': true})), 'vim.mode.normal'); + }); + + test('visual mode → vim.mode.normal', () { + expect(_cmd(esc(const {'vim.visual': true})), 'vim.mode.normal'); + }); + + test('normal mode → panel.focusMode.exit (Esc still exits there)', () { + expect(_cmd(esc(const {'vim.normal': true})), 'panel.focusMode.exit'); + }); + + test('non-vim preset (no vim scope) → panel.focusMode.exit', () { + expect(_cmd(esc(const {})), 'panel.focusMode.exit'); + }); + }); +}