add Vim mode service, mode commands, and status indicator

T-207, first foundation piece of the Vim layer (T-65 epic). A
VimModeService (ChangeNotifier) owns the normal/insert/visual mode and
mirrors it into the keymap as mutually-exclusive vim.normal/vim.insert/
vim.visual scope flags. Those flags are the public mode interface: the
editor (T-206) will read them to decide insert-vs-command, and vim.yaml
(T-65) guards bindings with `when: vim.*`. Nothing reaches across the
builtin boundary into the service object.

The layer is gated on the active preset — the builtin.vim extension
ties VimModeService.enabled to app.keymap.preset and re-checks on every
keymap reload, so i/v/Esc never hijack input under non-Vim presets. Mode
commands (vim.mode.{normal,insert,visual}) carry no default binding for
the same reason; only vim.yaml binds keys to them. A status-bar item
shows `-- NORMAL --` etc. while enabled.

Exposes KeymapService on the extension context so the layer can publish
scope flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:59:01 +02:00
co-authored by Claude Opus 4.8
parent 4aa24a9898
commit 61b969d010
12 changed files with 448 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import 'package:clide/builtin/vim/src/vim_mode_indicator.dart';
import 'package:clide/builtin/vim/src/vim_mode_service.dart';
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
/// Vim layer (T-65 epic). Owns the [VimModeService], registers the
/// mode-transition commands the `vim.yaml` preset binds to, and shows the
/// current mode in the status bar.
///
/// The layer is inert unless the active preset is `vim`: [activate] ties
/// [VimModeService.enabled] to the `app.keymap.preset` setting and
/// re-checks it whenever the keymap reloads (preset switches go through
/// `KeymapService.load`, which notifies). That keeps `i` / `v` / `Esc`
/// from hijacking input under the default / VS Code / JetBrains presets.
///
/// Mode commands carry NO `defaultBinding` — binding them globally would
/// fire under every preset. Only `vim.yaml` (guarded by `when: vim.*`)
/// binds keys to them.
class VimExtension extends ClideExtension {
@override
String get id => 'builtin.vim';
@override
String get title => 'Vim';
@override
String get version => '0.1.0';
@override
List<String> get dependsOn => const [];
VimModeService? _mode;
KeymapService? _keymap;
SettingsStore? _settings;
/// Exposed for tests/host wiring; null before [activate].
VimModeService? get modeService => _mode;
@override
Future<void> activate(ClideExtensionContext ctx) async {
final mode = VimModeService(ctx.keymap);
_mode = mode;
_keymap = ctx.keymap;
_settings = ctx.settings;
_syncEnabled();
ctx.keymap.addListener(_syncEnabled);
}
/// Enable the Vim layer iff the active preset is `vim`.
void _syncEnabled() {
final preset = _settings?.get<String>(kKeymapPresetSetting) ?? 'default';
_mode?.enabled = preset == 'vim';
}
@override
Future<void> deactivate() async {
_keymap?.removeListener(_syncEnabled);
_mode?.enabled = false;
_mode?.dispose();
_mode = null;
}
@override
List<ContributionPoint> get contributions => [
_modeCommand('vim.mode.normal', 'Vim: Normal mode', () => _mode?.enterNormal()),
_modeCommand('vim.mode.insert', 'Vim: Insert mode', () => _mode?.enterInsert()),
_modeCommand('vim.mode.visual', 'Vim: Visual mode', () => _mode?.enterVisual()),
StatusItemContribution(
id: 'vim.mode',
priority: -50, // left group, near the other editor status items
listenable: _mode,
build: (_) {
final m = _mode;
return m == null ? const SizedBox.shrink() : VimModeIndicator(service: m);
},
),
];
CommandContribution _modeCommand(String id, String title, void Function() apply) {
return CommandContribution(
id: id,
command: id,
title: title,
run: (_) async {
apply();
return IpcResponse.ok(id: '', data: {'mode': _mode?.mode.name ?? 'disabled'});
},
);
}
}
@@ -0,0 +1,33 @@
import 'package:clide/builtin/vim/src/vim_mode_service.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Status-bar item showing the current Vim mode (`-- NORMAL --`). Renders
/// nothing while the Vim layer is disabled, so it's invisible under
/// non-Vim presets (T-207).
class VimModeIndicator extends StatelessWidget {
const VimModeIndicator({super.key, required this.service});
final VimModeService service;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: service,
builder: (context, _) {
if (!service.enabled) return const SizedBox.shrink();
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClideText(
'-- ${service.mode.label} --',
fontFamily: clideMonoFamily,
fontSize: clideFontCaption,
color: tokens.statusBarForeground,
),
);
},
);
}
}
+88
View File
@@ -0,0 +1,88 @@
/// Owns the current Vim editing mode and mirrors it into the keymap as
/// scope flags (T-207).
///
/// The mode is the single source of truth for "is the editor modal right
/// now, and in which mode" — but the *public* interface other subsystems
/// consume is the set of `vim.*` scope flags this service pushes into
/// [KeymapService]. The editor (T-206) decides whether a bare key inserts
/// text or drives a motion by reading `vim.normal` from the keymap scope;
/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal`
/// etc. Nothing reaches into this object across the builtin boundary.
///
/// The whole layer is gated by [enabled], which the Vim extension ties to
/// the active preset: under a non-Vim preset the flags are cleared so they
/// can never affect another preset's bindings.
library;
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:flutter/foundation.dart';
/// The three editing modes clide models. Vim's other sub-modes
/// (visual-line, visual-block, replace, command-line) are out of scope
/// for the first pass; `command-line` is surfaced separately as a
/// transient overlay rather than a persistent mode.
enum VimMode {
normal('vim.normal', 'NORMAL'),
insert('vim.insert', 'INSERT'),
visual('vim.visual', 'VISUAL');
const VimMode(this.scopeFlag, this.label);
/// The keymap scope flag set true exactly when this mode is active.
final String scopeFlag;
/// Status-bar label, shown as `-- NORMAL --` (Vim's own vocabulary;
/// intentionally untranslated, like Vim itself).
final String label;
}
class VimModeService extends ChangeNotifier {
VimModeService(this._keymap);
final KeymapService _keymap;
bool _enabled = false;
VimMode _mode = VimMode.normal;
/// Whether the Vim layer is live. False under non-Vim presets.
bool get enabled => _enabled;
/// The active mode. Meaningful only while [enabled]; defaults to
/// [VimMode.normal] and resets to it whenever the layer is enabled.
VimMode get mode => _mode;
/// Turn the Vim layer on or off. Enabling resets to normal mode and
/// publishes the scope flags; disabling clears every `vim.*` flag so a
/// non-Vim preset's bindings are never shadowed.
set enabled(bool value) {
if (_enabled == value) return;
_enabled = value;
if (_enabled) {
_mode = VimMode.normal;
_publish();
} else {
for (final m in VimMode.values) {
_keymap.clearScopeFlag(m.scopeFlag);
}
}
notifyListeners();
}
void enterNormal() => _setMode(VimMode.normal);
void enterInsert() => _setMode(VimMode.insert);
void enterVisual() => _setMode(VimMode.visual);
void _setMode(VimMode mode) {
if (!_enabled || _mode == mode) return;
_mode = mode;
_publish();
notifyListeners();
}
/// Set exactly one `vim.*` flag (the active mode) true, the rest false.
void _publish() {
for (final m in VimMode.values) {
_keymap.setScopeFlag(m.scopeFlag, m == _mode);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
/// Vim layer (T-65): modal mode tracking, mode-transition commands, and a
/// status-bar mode indicator. Active only under the `vim` keymap preset.
library;
export 'src/extension.dart';
export 'src/vim_mode_indicator.dart';
export 'src/vim_mode_service.dart';
+2
View File
@@ -10,6 +10,7 @@ import 'package:clide/kernel/src/files.dart';
import 'package:clide/kernel/src/focus.dart';
import 'package:clide/kernel/src/i18n/i18n.dart';
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/net.dart';
import 'package:clide/kernel/src/notify.dart';
@@ -63,6 +64,7 @@ abstract class ClideExtensionContext {
PanelRegistry get panels;
LayoutArrangement get arrangement;
CommandRegistry get commands;
KeymapService get keymap;
PaletteController get palette;
ReaderNavRegistry get readerNav;
ClideClipboard get clipboard;
+2
View File
@@ -290,6 +290,8 @@ class _ExtensionContext implements ClideExtensionContext {
@override
CommandRegistry get commands => manager.commands;
@override
KeymapService get keymap => manager.keymap;
@override
PaletteController get palette => manager.palette;
@override
ReaderNavRegistry get readerNav => manager.readerNav;
+2
View File
@@ -24,6 +24,7 @@ import 'package:clide/builtin/settings_ui/settings_ui.dart';
import 'package:clide/builtin/terminal/terminal.dart';
import 'package:clide/builtin/theme_picker/theme_picker.dart';
import 'package:clide/builtin/view/view.dart';
import 'package:clide/builtin/vim/vim.dart';
import 'package:clide/builtin/tickets/tickets.dart';
import 'package:clide/builtin/todos/todos.dart';
import 'package:clide/builtin/welcome/welcome.dart';
@@ -265,6 +266,7 @@ Future<void> main() async {
..register(ClaudeExtension())
..register(TerminalExtension())
..register(EditorExtension())
..register(VimExtension())
..register(DiffExtension())
// Format engines + stubs
..register(GrammarsCoreExtension())