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).
**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).
**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
- 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)
@@ -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)
+7
View File
@@ -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<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
// 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);
@@ -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');
});
});
}