fix Esc closing the editor instead of returning to Vim normal mode

panel.focusMode.exit is bound to Escape in the contributions layer, which
outranks the active preset. In Vim insert/visual mode that shadowed the
vim preset's `escape → vim.mode.normal` binding, so Esc closed the editor
instead of returning to normal mode.

Add an optional when-clause to a CommandContribution's defaultBinding
(plumbed through to KeymapService.registerCommandBinding, which already
accepts one) and guard focusMode.exit's escape with
`!vim.insert && !vim.visual` — symmetric with vim.yaml's vim.mode.normal.
Esc still exits focus / closes the editor in normal and non-Vim modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 20:58:14 +02:00
co-authored by Claude Opus 4.8
parent f0fa602ff4
commit 635ae04dae
7 changed files with 87 additions and 1 deletions
@@ -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). **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; **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;
+1
View File
@@ -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). **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); **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);
+3
View File
@@ -128,6 +128,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed ### 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 - 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 status line. The mode was changed on the session but never reflected back, so
both looked dead. (T-250) both looked dead. (T-250)
@@ -79,6 +79,10 @@ class DefaultLayoutExtension extends ClideExtension {
command: 'panel.focusMode.exit', command: 'panel.focusMode.exit',
title: 'Exit Focus Mode', title: 'Exit Focus Mode',
defaultBinding: 'escape', 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, run: _exitFocusMode,
), ),
// Editor split (D-049, D-054) // Editor split (D-049, D-054)
+7
View File
@@ -107,11 +107,18 @@ class CommandContribution extends ContributionPoint {
required this.run, required this.run,
this.title, this.title,
this.defaultBinding, this.defaultBinding,
this.bindingWhen,
}); });
final String command; // e.g. "git.commit" final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged" final String? title; // "Git: Commit staged"
final String? defaultBinding; // e.g. "ctrl+shift+g" 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<IpcResponse> Function(List<String> args) run; final Future<IpcResponse> Function(List<String> args) run;
} }
+1 -1
View File
@@ -205,7 +205,7 @@ class ExtensionManager extends ChangeNotifier {
// until all callers migrate; the keymap layer is the // until all callers migrate; the keymap layer is the
// canonical home for chord → command bindings (T-117). // canonical home for chord → command bindings (T-117).
keybindings.bind(Keybinding.parse(binding), cmd.command); keybindings.bind(Keybinding.parse(binding), cmd.command);
keymap.registerCommandBinding(binding, cmd.command); keymap.registerCommandBinding(binding, cmd.command, when: cmd.bindingWhen);
} }
case TrayItemContribution t: case TrayItemContribution t:
tray.add(t); tray.add(t);
@@ -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<CommandContribution>().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<String, bool> 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');
});
});
}