feat(vim): ex command-line overlay (:w :q :wq :x :e :N, ZZ) (T-407)

Under the Vim preset, `:` opens a transient one-line ex overlay running a
fixed v1 table; ZZ runs :wq directly. Completes the last built child of the
T-403 cross-pane vim layer (T-405 part 2 gt/gT still open).

- ExLineController + parseExCommand grammar + editor-targeted executors
  (lib/kernel/src/ex_line.dart); the overlay (lib/widgets/src/ex_line_overlay
  .dart) reuses the quick-open chrome, mounts in the root_shell Stack, and
  publishes the exline.open scope flag. Unknown commands flash + stay open;
  with no active buffer every command no-ops (2026-06-13 decision).
- :q closes the active tab via editor.close on its id — the registry promotes
  the next buffer and the split self-collapses on the last (2026-06-12
  decision); :w/:wq/:x/ZZ save (+close) the active buffer.
- :e <path> seeds quick-open (new QuickOpenController.open(seed:)); :N adds the
  editor.goto-line IPC/CLI verb (reuses _offsetForLine). Goto needs caret sync:
  EditorController now handles editor.selection-changed and the editor view
  moves the caret on a selection-only change.
- `:` and ZZ are typed intents; the editor matcher and PaneKeyNav now bubble
  unhandled typed intents to the app-root Actions, so they fire from any focus.
  vim.yaml binds `:`, ZZ (shift+z shift+z), and Esc-dismiss.

Tests: parser/controller/executors, editor.goto-line daemon tests,
selection-changed (controller + view), full overlay widget test. make test
green; analyze + format clean.

Also files T-441 (drop bold from the ticket-id card label) and T-442
(sub-agent renders as 3 cards instead of one bundle) under the T-276 UI epic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:44:48 +02:00
co-authored by Claude Opus 4.8
parent c5e54ee0ac
commit e54d5263e0
22 changed files with 1269 additions and 8 deletions
+183
View File
@@ -0,0 +1,183 @@
/// State + grammar + execution for the Vim ex command-line overlay (T-407).
///
/// `:` (under `vim.normal`) opens a transient one-line prompt that runs a
/// small, fixed table of ex commands (`:w` `:q` `:wq` `:x` `:e <path>` `:N`).
/// It is NOT a vim *mode* — it's an overlay with its own `exline.open` scope
/// flag, dismissed with Esc, exactly the deferral `vim_mode_service.dart`
/// always named. The controller mirrors [QuickOpenController]'s open/close
/// shape so the overlay can reuse the quick-open chrome.
///
/// The command grammar ([parseExCommand]) is a pure switch — no parser, no
/// vimscript. Execution ([exWriteActive] etc.) goes through the editor IPC
/// verbs (the daemon is the source of truth for buffer state), so every ex
/// command is editor-targeted and no-ops when no buffer is active (the
/// 2026-06-13 decision on T-407).
library;
import 'dart:async';
import 'package:clide/clide.dart' show IpcResponse;
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:flutter/foundation.dart';
class ExLineController extends ChangeNotifier {
bool _open = false;
String _input = '';
// Bumped each time an unknown command is rejected so the overlay can flash
// without closing. A monotonic nonce (not a bool) keeps repeated rejections
// individually observable by a listener.
int _invalidNonce = 0;
bool get isOpen => _open;
String get input => _input;
/// Increments whenever a typed command is rejected ([flashInvalid]); the
/// overlay watches it to flash the input and stay open.
int get invalidNonce => _invalidNonce;
void open() {
if (_open) return;
_open = true;
_input = '';
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
_input = '';
notifyListeners();
}
void setInput(String value) {
if (_input == value) return;
_input = value;
notifyListeners();
}
/// Signal that the submitted command was unknown — the overlay flashes and
/// stays open rather than executing or dismissing.
void flashInvalid() {
_invalidNonce++;
notifyListeners();
}
}
// --- Grammar ---------------------------------------------------------------
/// One parsed ex command. v1 table; anything off it is [ExUnknown].
sealed class ExCommand {
const ExCommand();
}
/// Empty input (`:` then Enter) — dismiss with no action.
class ExNoop extends ExCommand {
const ExNoop();
}
/// `:w` — write (save) the active buffer.
class ExWrite extends ExCommand {
const ExWrite();
}
/// `:q` (and `:q!`) — close the active editor tab.
class ExQuit extends ExCommand {
const ExQuit();
}
/// `:wq` / `:x` (and bang variants) and `ZZ` — save then close the active tab.
class ExWriteQuit extends ExCommand {
const ExWriteQuit();
}
/// `:e <path>` — open quick-open seeded with `<path>` (empty seed allowed).
class ExEdit extends ExCommand {
const ExEdit(this.query);
final String query;
@override
bool operator ==(Object other) => other is ExEdit && other.query == query;
@override
int get hashCode => query.hashCode;
}
/// `:<n>` — jump the active buffer to 1-based line `<n>`.
class ExGoto extends ExCommand {
const ExGoto(this.line);
final int line;
@override
bool operator ==(Object other) => other is ExGoto && other.line == line;
@override
int get hashCode => line.hashCode;
}
/// Anything not on the v1 table — the overlay flashes and stays open.
class ExUnknown extends ExCommand {
const ExUnknown();
}
/// Parse the text typed after `:` into an [ExCommand]. A leading colon is
/// tolerated (in case the user types it). v1 grammar only.
ExCommand parseExCommand(String raw) {
var s = raw.trim();
if (s.startsWith(':')) s = s.substring(1).trim();
if (s.isEmpty) return const ExNoop();
// `:e` / `:e <path>` — everything after the first token seeds quick-open.
if (s == 'e') return const ExEdit('');
if (s.startsWith('e ')) return ExEdit(s.substring(2).trim());
switch (s) {
case 'w':
return const ExWrite();
case 'q' || 'q!':
// No dirty-guard in v1, so `q!` is just `q`.
return const ExQuit();
case 'wq' || 'wq!' || 'x' || 'x!':
return const ExWriteQuit();
}
final line = int.tryParse(s);
if (line != null && line >= 1) return ExGoto(line);
return const ExUnknown();
}
// --- Execution (editor-targeted; no-op when no active buffer) ---------------
/// The active editor buffer id, or null when no buffer is active.
Future<String?> activeEditorBufferId(DaemonClient ipc) async {
final IpcResponse r = await ipc.request('editor.active');
if (!r.ok) return null;
final active = r.data['active'];
return active is Map ? active['id'] as String? : null;
}
/// `:w` — save the active buffer. `editor.save` resolves the active buffer
/// server-side, so a missing buffer is a silent no-op.
Future<void> exWriteActive(DaemonClient ipc) async {
await ipc.request('editor.save');
}
/// `:q` — close the active editor tab. The registry promotes the next buffer
/// (or collapses the split on the last one) — no separate split-close needed.
Future<void> exQuitActive(DaemonClient ipc) async {
final id = await activeEditorBufferId(ipc);
if (id == null) return;
await ipc.request('editor.close', args: {'id': id});
}
/// `:wq` / `:x` / `ZZ` — save the active buffer then close its tab.
Future<void> exWriteQuitActive(DaemonClient ipc) async {
final id = await activeEditorBufferId(ipc);
if (id == null) return;
await ipc.request('editor.save', args: {'id': id});
await ipc.request('editor.close', args: {'id': id});
}
/// `:<n>` — jump the active buffer to 1-based [line]. `editor.goto-line`
/// resolves the active buffer server-side and clamps out-of-range lines.
Future<void> exGotoLineActive(DaemonClient ipc, int line) async {
await ipc.request('editor.goto-line', args: {'line': line});
}
+8
View File
@@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart';
import 'package:clide/kernel/src/os.dart';
import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/ex_line.dart';
import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/quick_open.dart';
import 'package:clide/kernel/src/reader_nav.dart';
@@ -58,6 +59,7 @@ class KernelServices {
required this.commands,
required this.palette,
required this.quickOpen,
required this.exLine,
required this.recentFiles,
required this.readerNav,
required this.keybindings,
@@ -97,6 +99,9 @@ class KernelServices {
final CommandRegistry commands;
final PaletteController palette;
final QuickOpenController quickOpen;
/// Transient Vim ex command-line overlay state (T-407).
final ExLineController exLine;
final RecentFilesService recentFiles;
final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings;
@@ -167,6 +172,7 @@ class KernelServices {
final palette = PaletteController(commands);
final recentFiles = RecentFilesService();
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
final exLine = ExLineController();
final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard();
final files = FileServices(events);
@@ -253,6 +259,7 @@ class KernelServices {
commands: commands,
palette: palette,
quickOpen: quickOpen,
exLine: exLine,
recentFiles: recentFiles,
readerNav: readerNav,
keybindings: keybindings,
@@ -286,6 +293,7 @@ class KernelServices {
commands.dispose();
palette.dispose();
quickOpen.dispose();
exLine.dispose();
toast.dispose();
recentFiles.dispose();
readerNav.dispose();
+18
View File
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
const QuickOpenAcceptIntent();
}
// -- Vim ex command-line (T-407) --------------------------------------------
/// Open the Vim ex command-line overlay (`:`). A typed intent (not a
/// `command:` bridge) so it survives the editor's command-mode matcher and a
/// focused pane's nav matcher, both of which bubble unhandled typed intents to
/// the app-root Actions where this resolves to `services.exLine.open()`.
class ExLineOpenIntent extends Intent {
const ExLineOpenIntent();
}
/// Save the active buffer and close its tab (`ZZ`), without opening the
/// overlay — shares the `:wq` execution path.
class ExLineWriteQuitIntent extends Intent {
const ExLineWriteQuitIntent();
}
// -- Find in files ----------------------------------------------------------
/// Reveal the find-in-files search panel in the sidebar.
@@ -193,6 +209,8 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(),
'exline.open': () => const ExLineOpenIntent(),
'exline.writeQuit': () => const ExLineWriteQuitIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(),
+10 -3
View File
@@ -102,9 +102,16 @@ class _PaneKeyNavState extends State<PaneKeyNav> {
switch (r.outcome) {
case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
// editor.vim.* with no focus guard) is swallowed, never executed here.
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
// 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).
final fired = r.intent;
if (fired is NavIntent) {
widget.onNav(fired, r.count);
} else if (fired != null && fired is! InvokeCommandIntent) {
Actions.maybeInvoke(context, fired);
}
return KeyEventResult.handled;
case SeqOutcome.pending:
return KeyEventResult.handled;
+4 -2
View File
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
return _selectedIndex.clamp(0, n - 1);
}
void open() {
/// Open the picker. An optional [seed] pre-fills the filter — used by the
/// ex-line `:e <path>` command (T-407) to jump straight to a query.
void open({String? seed}) {
if (_open) return;
_open = true;
_filter = '';
_filter = seed ?? '';
_selectedIndex = 0;
notifyListeners();
}