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
+147
View File
@@ -5604,3 +5604,150 @@ Closed 2026-06-15: the dart:ffi fence + flutter build web --wasm compile gate ar
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'review', 'done', NULL, '2026-06-15 15:53:47', '2026-06-15 15:53:47', '2026-06-15 15:53:47', NULL, '799f878cdf6c8f6b9256fb852b4c4537', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'review', 'done', NULL, '2026-06-15 15:53:47', '2026-06-15 15:53:47', '2026-06-15 15:53:47', NULL, '799f878cdf6c8f6b9256fb852b4c4537', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'review', 'done', NULL, '2026-06-15 15:59:15', '2026-06-15 15:59:15', '2026-06-15 15:59:15', NULL, '96e17294ee3609fa7f2dd9a69fa38fa6', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'review', 'done', NULL, '2026-06-15 15:59:15', '2026-06-15 15:59:15', '2026-06-15 15:59:15', NULL, '96e17294ee3609fa7f2dd9a69fa38fa6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'review', 'done', NULL, '2026-06-15 16:03:07', '2026-06-15 16:03:07', '2026-06-15 16:03:07', NULL, '75bf3f398e16ec94668b9d7937c33a4e', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'review', 'done', NULL, '2026-06-15 16:03:07', '2026-06-15 16:03:07', '2026-06-15 16:03:07', NULL, '75bf3f398e16ec94668b9d7937c33a4e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'status', 'in_progress', 'backlog', NULL, '2026-06-15 16:14:56', '2026-06-15 16:14:56', '2026-06-15 16:14:56', NULL, '085d54bd54f253b684cea7fd61638cee', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'description', NULL, 'The ticket-id label at the top of each kanban card (e.g. "T-403") is rendered in semibold and is hard to read at the small card font size — user feedback 2026-06-16 (screenshot: ~/.cache/clide/pasted/paste-1781601250588.png).
**Fix:** drop the bold weight from the id label. lib/builtin/tickets/src/tickets_view.dart:405 `ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600)` remove `fontWeight: FontWeight.w600` so it renders at the default UI weight. Confirm the parent-id breadcrumb line (the muted ` T-NNN` above a child, ~line 385-395) still reads fine; keep the title (line 409) as-is.
Scope: cosmetic weight tweak only. Verify against the four presets/themes; no golden churn expected beyond the tickets-view widget golden if one exists.', NULL, '2026-06-16 09:16:18', '2026-06-16 09:16:18', '2026-06-16 09:16:18', NULL, '3b414a2fe4ba72623f59d3e461082bf7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'description', NULL, 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781601988494.png, observed in the settled-reach session). An Agent/Task tool interaction renders as THREE sibling top-level cards instead of one bundled, collapsed card:
1. `> Agent Implement T-1039+T-1043 dispatch threading` 1 step, </> badge (the Agent tool-use collapser)
2. `> agent run Edit //server/src/atlas/plugin.rs` 65 steps
3. `> agent prompt You are DUDLEY, server developer`
EXPECTED (per T-305 collapser + T-338 sidechain fold): the sub-agent''s run + prompt + its 65 steps should be bundled INSIDE the single Agent collapser card, not surfaced as siblings. The ''1 step'' on the Agent card next to a separate ''65 steps'' card is the tell the step count that belongs to the agent landed on a different card than its work.
QUESTIONS TO RESOLVE:
- Is the three-card split a regression in the sidechain fold, or are ''agent run''/''agent prompt'' a distinct (hosted-agent) render path that never went through the collapser? Determine which.
- The stray/inconsistent left indent on these cards (user also flagged) confirm and remove so the bundle reads as one unit.
CODE POINTERS: lib/builtin/claude/src/activity_cluster.dart (isAgentTool: Task|Agent, line 83); lib/builtin/claude/src/transcript_reader.dart (isSidechain / parentToolUseId fold, ~line 571-575; subagents/agent-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', NULL, '2026-06-16 09:27:53', '2026-06-16 09:27:53', '2026-06-16 09:27:53', NULL, '8a6a6a533fd388d09a31a1f23af55fa5', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer test-friendly).
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', NULL, '2026-06-16 09:42:34', '2026-06-16 09:42:34', '2026-06-16 09:42:34', NULL, 'c53afd4a1354012e36081b84dab66bfd', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'ready', 'review', NULL, '2026-06-16 09:42:39', '2026-06-16 09:42:39', '2026-06-16 09:42:39', NULL, 'e2cc00b9f1b80080e17e0213079400f1', 2) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -265,3 +265,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'T-441', '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, 'fbabc4fc344b34476b38a186c2f44a16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'T-442', '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, 'c85d8fd95cd73aec1e83b477612e1131', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+168
View File
@@ -6523,3 +6523,171 @@ Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart
Verified: `flutter analyze` clean; new `shell_env_test.dart` covers the probe + every fallback + the merge; `env_test`/`cli_install_test` updated; `make test` green; `flutter build web --wasm` still green (resolver is web-safe, probe is `!kIsWeb`). Verified: `flutter analyze` clean; new `shell_env_test.dart` covers the probe + every fallback + the merge; `env_test`/`cli_install_test` updated; `make test` green; `flutter build web --wasm` still green (resolver is web-safe, probe is `!kIsWeb`).
**Why `review`, not `done`:** logic + tests cover Linux and macOS, but the real *desktop/dock-launch* behavior (the actual GUI process inheriting a sparse PATH) can only be confirmed by launching a packaged build from the launcher recommend a quick live confirm (open clide from the dock, check pql/git/a `~/.local/bin` tool resolve). macOS Homebrew path is code-correct but unverified on a Mac.', 'done', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 16:03:07', NULL, 'e9e65365ca2c33edecd72e30a76c3e67', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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); **Why `review`, not `done`:** logic + tests cover Linux and macOS, but the real *desktop/dock-launch* behavior (the actual GUI process inheriting a sparse PATH) can only be confirmed by launching a packaged build from the launcher recommend a quick live confirm (open clide from the dock, check pql/git/a `~/.local/bin` tool resolve). macOS Homebrew path is code-correct but unverified on a Mac.', 'done', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 16:03:07', NULL, 'e9e65365ca2c33edecd72e30a76c3e67', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 2 — remote workspace identity + ssh:// open scheme', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).
2026-06-12 progress: the model-independent slice is in WorkspaceRef value type (lib/kernel/src/workspace_ref.dart: local/remote ctors, ssh://[user@]host[:port]/abs/path parse with rejection of host-less/path-less forms, uri/display, value equality; exported from kernel.dart) and RecentProject host/port/user (back-compatible JSON absent keys deserialize local; isRemote; ref getter; relativePath renders host:path; copyWith preserves host identity). Tests: test/kernel/src/workspace_ref_test.dart + new RecentProject cases in project_test.dart.
REMAINING (gated on T-330''s footprint pick / T-336 execution layer): migrate ProjectManager._current/current off bare Directory onto WorkspaceRef (touch all .current?.path callers), open() branching on isRemote, resolveProject remote toplevel via the execution layer, swapBackend remote branch (DaemonTransport seam from T-331 is ready), connection lifecycle + preflight + events --since re-sync. The ssh://localhost loopback verify also needs a reachable sshd (none on the dev box).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-15 16:14:56', NULL, '41befae79bb026e68d4b7a91c485f71a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket card: drop bold from the ticket-id label (hard to read)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, '6b822ca870b90597ae120773c4208d57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket card: drop bold from the ticket-id label (hard to read)', 'The ticket-id label at the top of each kanban card (e.g. "T-403") is rendered in semibold and is hard to read at the small card font size — user feedback 2026-06-16 (screenshot: ~/.cache/clide/pasted/paste-1781601250588.png).
**Fix:** drop the bold weight from the id label. lib/builtin/tickets/src/tickets_view.dart:405 `ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600)` remove `fontWeight: FontWeight.w600` so it renders at the default UI weight. Confirm the parent-id breadcrumb line (the muted ` T-NNN` above a child, ~line 385-395) still reads fine; keep the title (line 409) as-is.
Scope: cosmetic weight tweak only. Verify against the four presets/themes; no golden churn expected beyond the tickets-view widget golden if one exists.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-16 09:16:07', '2026-06-16 09:16:18', NULL, 'b6d0d8c954fc598cf6d9c09034eb81c9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Sub-agent interaction renders as 3 separate cards instead of one bundled collapsed card', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, '4dad9945833992749dd3b257263263c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Sub-agent interaction renders as 3 separate cards instead of one bundled collapsed card', 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781601988494.png, observed in the settled-reach session). An Agent/Task tool interaction renders as THREE sibling top-level cards instead of one bundled, collapsed card:
1. `> Agent Implement T-1039+T-1043 dispatch threading` 1 step, </> badge (the Agent tool-use collapser)
2. `> agent run Edit //server/src/atlas/plugin.rs` 65 steps
3. `> agent prompt You are DUDLEY, server developer`
EXPECTED (per T-305 collapser + T-338 sidechain fold): the sub-agent''s run + prompt + its 65 steps should be bundled INSIDE the single Agent collapser card, not surfaced as siblings. The ''1 step'' on the Agent card next to a separate ''65 steps'' card is the tell the step count that belongs to the agent landed on a different card than its work.
QUESTIONS TO RESOLVE:
- Is the three-card split a regression in the sidechain fold, or are ''agent run''/''agent prompt'' a distinct (hosted-agent) render path that never went through the collapser? Determine which.
- The stray/inconsistent left indent on these cards (user also flagged) confirm and remove so the bundle reads as one unit.
CODE POINTERS: lib/builtin/claude/src/activity_cluster.dart (isAgentTool: Task|Agent, line 83); lib/builtin/claude/src/transcript_reader.dart (isSidechain / parentToolUseId fold, ~line 571-575; subagents/agent-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 09:27:39', '2026-06-16 09:27:53', NULL, '01f87c62ce6ad7d1266d0acb10a38d01', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer test-friendly).
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:42:34', NULL, '1c0b6cd5dc6d81a9b4deb0f35a48e700', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer test-friendly).
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'review', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:42:39', NULL, 'e320e4acb9a1a9100e39ebf8199e72d2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+8
View File
@@ -18,6 +18,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added ### Added
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
commands flash and stay open; with no active buffer it no-ops. Opens from the
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
verb. (T-407)
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a - **Crash-survivable logging.** clide writes a durable JSON-lines log to a
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`, persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
+17
View File
@@ -50,6 +50,11 @@ bindings:
- intent: dismiss - intent: dismiss
keys: escape keys: escape
when: quickOpen.open when: quickOpen.open
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
# overlay's own EditableText handles Enter via onSubmitted).
- intent: dismiss
keys: escape
when: exline.open
- intent: findInFiles.open - intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f] keys: [ctrl+shift+f, meta+shift+f]
- intent: focus.nextPanel - intent: focus.nextPanel
@@ -128,6 +133,18 @@ bindings:
keys: [ctrl+w q, ctrl+w c] keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual" when: "vim.normal || vim.visual"
# ---- Ex command-line (T-407) -----------------------------------------
# `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e <path>` `:N`);
# `ZZ` runs `:wq` directly. Both are typed app intents the editor's command
# matcher and a pane's nav matcher bubble to the app-root Actions, so they
# fire from any focus. Editor-targeted: no-op when no buffer is active.
- intent: exline.open
keys: shift+semicolon # :
when: vim.normal
- intent: exline.writeQuit
keys: shift+z shift+z # ZZ
when: vim.normal
# ---- Mode transitions ------------------------------------------------ # ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual - intent: command:vim.mode.visual
keys: v keys: v
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
_dirty = false; _dirty = false;
notifyListeners(); notifyListeners();
} }
case 'editor.selection-changed':
// An external setSelection moved the caret server-side (find-in-files
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
// buffer so the view's caret follows. Skipped while our own local edits
// are in flight — their echo already carries the authoritative caret.
final id = e.data['id'] as String?;
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
final sel = e.data['selection'];
if (sel is Map) {
_selection = Selection.fromJson(sel.cast<String, Object?>());
notifyListeners();
}
}
case 'editor.settings-changed': case 'editor.settings-changed':
// A source (e.g. a saved .editorconfig) re-resolved the buffer's // A source (e.g. a saved .editorconfig) re-resolved the buffer's
// settings. Refresh the active buffer's copy so indent/ruler update. // settings. Refresh the active buffer's copy so indent/ruler update.
+16 -2
View File
@@ -108,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
final c = _controller!; final c = _controller!;
_syncTabs(c); _syncTabs(c);
_text.updatePath(c.activePath); _text.updatePath(c.activePath);
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
if (c.content != _lastRemoteContent) { if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content; _lastRemoteContent = c.content;
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
_text.removeListener(_onTextChanged); _text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel); _text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged); _text.addListener(_onTextChanged);
} else if (sel != _text.value.selection) {
// Selection-only change from an external setSelection (ex-line `:N` goto,
// find-in-files line jump on the already-active buffer) — content is
// unchanged, so move just the caret. The focused field scrolls it into
// view (T-407).
_text.removeListener(_onTextChanged);
_text.value = _text.value.copyWith(selection: sel);
_text.addListener(_onTextChanged);
} }
setState(() {}); // tab/title refresh setState(() {}); // tab/title refresh
} }
@@ -274,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
} }
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) { void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
if (intent is! InvokeCommandIntent) return; if (intent is! InvokeCommandIntent) {
// A typed app intent the matcher fired (e.g. the ex-line `:` open or ZZ).
// The editor only owns editor.vim.* / mode commands; bubble anything else
// to the app-root Actions so it reaches its global handler (T-407).
Actions.maybeInvoke(context, intent);
return;
}
final id = intent.commandId; final id = intent.commandId;
if (!id.startsWith('editor.vim.')) { if (!id.startsWith('editor.vim.')) {
// Mode change (vim.mode.*) or any other command. // Mode change (vim.mode.*) or any other command.
+1
View File
@@ -34,6 +34,7 @@ export 'src/keymap/pane_key_nav.dart';
export 'src/keymap/sequence_matcher.dart'; export 'src/keymap/sequence_matcher.dart';
export 'src/keymap/when_clause.dart'; export 'src/keymap/when_clause.dart';
export 'src/dialog.dart'; export 'src/dialog.dart';
export 'src/ex_line.dart';
export 'src/extensions_manager.dart'; export 'src/extensions_manager.dart';
export 'src/file_open.dart'; export 'src/file_open.dart';
export 'src/files.dart'; export 'src/files.dart';
+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/os.dart';
import 'package:clide/kernel/src/panels/arrangement.dart'; import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.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/project.dart';
import 'package:clide/kernel/src/quick_open.dart'; import 'package:clide/kernel/src/quick_open.dart';
import 'package:clide/kernel/src/reader_nav.dart'; import 'package:clide/kernel/src/reader_nav.dart';
@@ -58,6 +59,7 @@ class KernelServices {
required this.commands, required this.commands,
required this.palette, required this.palette,
required this.quickOpen, required this.quickOpen,
required this.exLine,
required this.recentFiles, required this.recentFiles,
required this.readerNav, required this.readerNav,
required this.keybindings, required this.keybindings,
@@ -97,6 +99,9 @@ class KernelServices {
final CommandRegistry commands; final CommandRegistry commands;
final PaletteController palette; final PaletteController palette;
final QuickOpenController quickOpen; final QuickOpenController quickOpen;
/// Transient Vim ex command-line overlay state (T-407).
final ExLineController exLine;
final RecentFilesService recentFiles; final RecentFilesService recentFiles;
final ReaderNavRegistry readerNav; final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings; final KeybindingResolver keybindings;
@@ -167,6 +172,7 @@ class KernelServices {
final palette = PaletteController(commands); final palette = PaletteController(commands);
final recentFiles = RecentFilesService(); final recentFiles = RecentFilesService();
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths); final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
final exLine = ExLineController();
final readerNav = ReaderNavRegistry(messages); final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard(); final clipboard = ClideClipboard();
final files = FileServices(events); final files = FileServices(events);
@@ -253,6 +259,7 @@ class KernelServices {
commands: commands, commands: commands,
palette: palette, palette: palette,
quickOpen: quickOpen, quickOpen: quickOpen,
exLine: exLine,
recentFiles: recentFiles, recentFiles: recentFiles,
readerNav: readerNav, readerNav: readerNav,
keybindings: keybindings, keybindings: keybindings,
@@ -286,6 +293,7 @@ class KernelServices {
commands.dispose(); commands.dispose();
palette.dispose(); palette.dispose();
quickOpen.dispose(); quickOpen.dispose();
exLine.dispose();
toast.dispose(); toast.dispose();
recentFiles.dispose(); recentFiles.dispose();
readerNav.dispose(); readerNav.dispose();
+18
View File
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
const QuickOpenAcceptIntent(); 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 ---------------------------------------------------------- // -- Find in files ----------------------------------------------------------
/// Reveal the find-in-files search panel in the sidebar. /// 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.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(), '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. // Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(), 'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(), 'nav.up': () => const NavUpIntent(),
+10 -3
View File
@@ -102,9 +102,16 @@ class _PaneKeyNavState extends State<PaneKeyNav> {
switch (r.outcome) { switch (r.outcome) {
case SeqOutcome.fired: case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a // 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 // pane only nav.* applies, and editor.vim.* (an InvokeCommandIntent) is
// editor.vim.* with no focus guard) is swallowed, never executed here. // swallowed — never run buffer edits from a pane. A typed *app* intent
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count); // (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; return KeyEventResult.handled;
case SeqOutcome.pending: case SeqOutcome.pending:
return KeyEventResult.handled; return KeyEventResult.handled;
+4 -2
View File
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
return _selectedIndex.clamp(0, n - 1); 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; if (_open) return;
_open = true; _open = true;
_filter = ''; _filter = seed ?? '';
_selectedIndex = 0; _selectedIndex = 0;
notifyListeners(); notifyListeners();
} }
+25 -1
View File
@@ -3,7 +3,7 @@
/// Verb list matches CLAUDE.md's tier-2 surface: /// Verb list matches CLAUDE.md's tier-2 surface:
/// editor.open editor.active editor.activate editor.insert /// editor.open editor.active editor.activate editor.insert
/// editor.replace-selection editor.save editor.close editor.list /// editor.replace-selection editor.save editor.close editor.list
/// editor.read editor.set-selection editor.set-content /// editor.read editor.set-selection editor.set-content editor.goto-line
/// ///
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map /// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
/// one-to-one onto these via the IPC dispatch layer. /// one-to-one onto these via the IPC dispatch layer.
@@ -50,6 +50,14 @@ void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) {
d.register('editor.set-content', (req) => _setContent(req, registry)); d.register('editor.set-content', (req) => _setContent(req, registry));
d.register('editor.save', (req) => _save(req, registry), schema: _idArg); d.register('editor.save', (req) => _save(req, registry), schema: _idArg);
d.register('editor.close', (req) => _close(req, registry), schema: _idArg); d.register('editor.close', (req) => _close(req, registry), schema: _idArg);
d.register(
'editor.goto-line',
(req) => _gotoLine(req, registry),
schema: const CommandSchema(
positional: ['line'],
args: {'line': ArgSpec(type: ArgType.number)},
),
);
} }
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err( IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
@@ -220,3 +228,19 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
r.close(id); r.close(id);
return IpcResponse.ok(id: req.id, data: {'id': id}); return IpcResponse.ok(id: req.id, data: {'id': id});
} }
/// Jump the active (or [id]'d) buffer's caret to the start of a 1-based line —
/// the ex-line `:N` goto (T-407) and the CLI `clide editor goto-line <n>`.
/// Reuses the [_offsetForLine] mapping `editor.open --line` uses; out-of-range
/// lines clamp to the buffer end via setSelection.
Future<IpcResponse> _gotoLine(IpcRequest req, EditorRegistry r) async {
final id = _resolveId(req, r);
if (id == null) return _notFound(req.id, 'no active buffer');
final buf = r.get(id);
if (buf == null) return _notFound(req.id, 'no such buffer: $id');
final rawLine = req.args['line'];
final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine');
if (line == null || line < 1) return _userErr(req.id, 'line must be a positive integer');
r.setSelection(id, Selection.collapsed(_offsetForLine(buf.content, line)));
return IpcResponse.ok(id: req.id, data: {'id': id, 'line': line});
}
+14
View File
@@ -120,6 +120,19 @@ class RootShellState extends State<RootShell> {
return null; return null;
}, },
), ),
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
onInvoke: (_) {
widget.services.exLine.open();
return null;
},
),
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
onInvoke: (_) {
// ZZ — save+close the active tab without opening the overlay.
unawaited(exWriteQuitActive(widget.services.ipc));
return null;
},
),
FindInFilesIntent: CallbackAction<FindInFilesIntent>( FindInFilesIntent: CallbackAction<FindInFilesIntent>(
onInvoke: (_) { onInvoke: (_) {
widget.services.arrangement.setVisible(Slots.sidebar, true); widget.services.arrangement.setVisible(Slots.sidebar, true);
@@ -160,6 +173,7 @@ class RootShellState extends State<RootShell> {
const Positioned.fill(child: RootLayout()), const Positioned.fill(child: RootLayout()),
const ClidePalette(), const ClidePalette(),
const QuickOpenOverlay(), const QuickOpenOverlay(),
const ExLineOverlay(),
const Positioned.fill(child: _WelcomeOverlay()), const Positioned.fill(child: _WelcomeOverlay()),
const ToastOverlay(), const ToastOverlay(),
], ],
+194
View File
@@ -0,0 +1,194 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// The Vim ex command-line overlay (T-407): a transient one-line `:` prompt
/// that runs the fixed v1 command table ([parseExCommand]). Modeled on the
/// quick-open chrome but single-line and result-less.
///
/// It is NOT a vim mode — while open it publishes the `exline.open` scope flag
/// (so `vim.yaml` can bind Esc → dismiss for it alone) and dismisses back to
/// normal mode with no mode churn. The `:` and `ZZ` entries open it / run
/// `:wq` from the keymap; this widget owns the input, dispatch, and the
/// rejected-command hint. Always mounted (like quick-open); inert unless the
/// keymap opens it, which only `vim.yaml` does.
class ExLineOverlay extends StatefulWidget {
const ExLineOverlay({super.key});
@override
State<ExLineOverlay> createState() => _ExLineOverlayState();
}
class _ExLineOverlayState extends State<ExLineOverlay> {
final _input = TextEditingController();
final _focus = FocusNode(debugLabel: 'ExLineOverlay.input');
ExLineController? _exLine;
KeymapService? _keymap;
KernelServices? _services;
/// True after a rejected (unknown) command, until the user edits the input.
/// Drives the red border + hint instead of a timed flash (test-friendly).
bool _rejected = false;
int _seenInvalidNonce = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final kernel = ClideKernel.of(context);
_services = kernel;
if (!identical(_exLine, kernel.exLine)) {
_exLine?.removeListener(_onChanged);
_exLine = kernel.exLine;
_seenInvalidNonce = _exLine!.invalidNonce;
_exLine!.addListener(_onChanged);
_syncFromController();
}
_keymap = kernel.keymap;
final isOpen = _exLine?.isOpen ?? false;
_keymap?.setScopeFlag('exline.open', isOpen);
if (isOpen && !_focus.hasFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && (_exLine?.isOpen ?? false)) _focus.requestFocus();
});
}
}
@override
void dispose() {
_exLine?.removeListener(_onChanged);
_keymap?.clearScopeFlag('exline.open');
_input.dispose();
_focus.dispose();
super.dispose();
}
void _onChanged() {
final controller = _exLine;
final isOpen = controller?.isOpen ?? false;
_keymap?.setScopeFlag('exline.open', isOpen);
if (isOpen) {
_focus.requestFocus();
} else {
_rejected = false;
}
if (controller != null && controller.invalidNonce != _seenInvalidNonce) {
_seenInvalidNonce = controller.invalidNonce;
_rejected = true;
}
_syncFromController();
}
void _syncFromController() {
final text = _exLine?.input ?? '';
if (_input.text != text) {
_input.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
if (mounted) setState(() {});
}
void _onInputChanged(String value) {
if (_rejected) setState(() => _rejected = false);
_exLine?.setInput(value);
}
Future<void> _submit() async {
final controller = _exLine;
final services = _services;
if (controller == null || services == null) return;
final cmd = parseExCommand(controller.input);
switch (cmd) {
case ExNoop():
controller.close();
case ExUnknown():
controller.flashInvalid(); // stay open; the hint + border show
case ExEdit(:final query):
controller.close();
services.quickOpen.open(seed: query.isEmpty ? null : query);
case ExWrite():
controller.close();
await exWriteActive(services.ipc);
case ExQuit():
controller.close();
await exQuitActive(services.ipc);
case ExWriteQuit():
controller.close();
await exWriteQuitActive(services.ipc);
case ExGoto(:final line):
controller.close();
await exGotoLineActive(services.ipc, line);
}
}
Object? _dismiss(DismissIntent _) {
_exLine?.close();
return null;
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.exLine,
builder: (ctx, _) {
if (!kernel.exLine.isOpen) return const SizedBox.shrink();
return Positioned(
top: 60,
left: 0,
right: 0,
child: Center(
child: Actions(
actions: <Type, Action<Intent>>{DismissIntent: CallbackAction<DismissIntent>(onInvoke: _dismiss)},
child: Container(
width: 480,
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: _rejected ? tokens.statusError : tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4))],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
ClideText(':', fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
const SizedBox(width: 4),
Expanded(
child: EditableText(
controller: _input,
focusNode: _focus,
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.dropdownForeground),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 1,
onChanged: _onInputChanged,
onSubmitted: (_) => _submit(),
),
),
],
),
),
if (_rejected)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ClideText('Not an editor command', fontSize: clideFontCaption, color: tokens.statusError),
),
],
),
),
),
),
);
},
);
}
}
+1
View File
@@ -14,6 +14,7 @@ export 'src/clide_collapser_card.dart';
export 'src/clide_code_block.dart'; export 'src/clide_code_block.dart';
export 'src/clide_divider.dart'; export 'src/clide_divider.dart';
export 'src/clide_file_image.dart'; export 'src/clide_file_image.dart';
export 'src/ex_line_overlay.dart';
export 'src/clide_filter_box.dart'; export 'src/clide_filter_box.dart';
export 'src/clide_lightbox.dart'; export 'src/clide_lightbox.dart';
export 'src/clide_markdown.dart'; export 'src/clide_markdown.dart';
@@ -187,6 +187,59 @@ void main() {
expect(c.activeId, isNull); // cleared until an active-changed arrives expect(c.activeId, isNull); // cleared until an active-changed arrives
}); });
test('editor.selection-changed mirrors an external caret move onto the active buffer (T-407)', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'line1\nline2')));
await c.hydrate();
expect(c.selection.start, 0);
// An external setSelection (ex-line :N goto) jumps the caret server-side.
emitEditor(bus, 'editor.selection-changed', {
'id': 'b_1',
'selection': {'start': 6, 'end': 6},
});
await pumpEventQueue();
expect(c.selection.start, 6);
expect(c.selection.end, 6);
});
test('editor.selection-changed for a non-active buffer is ignored', () async {
ipc.stub(
'editor.list',
(_) async => _ok({
'buffers': [_buf('b_1', 'a.dart')],
}),
);
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_1'},
}),
);
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'hello')));
await c.hydrate();
emitEditor(bus, 'editor.selection-changed', {
'id': 'b_other',
'selection': {'start': 3, 'end': 3},
});
await pumpEventQueue();
expect(c.selection.start, 0); // untouched
});
test('editor.saved clears the dirty marker on the buffer', () async { test('editor.saved clears the dirty marker on the buffer', () async {
ipc.stub( ipc.stub(
'editor.list', 'editor.list',
+27
View File
@@ -6,6 +6,7 @@ library;
import 'package:clide/builtin/editor/src/editor_view.dart'; import 'package:clide/builtin/editor/src/editor_view.dart';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -76,6 +77,32 @@ void main() {
expect(find.text('Open a file to begin editing.'), findsOneWidget); expect(find.text('Open a file to begin editing.'), findsOneWidget);
}); });
testWidgets('an external selection-changed moves the caret without retyping (T-407)', (tester) async {
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
await tester.pumpWidget(harness(f, const EditorView()));
await tester.pumpAndSettle();
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.selection.baseOffset, 0);
// An ex-line `:N` goto sets the selection server-side; the buffer content
// is unchanged, so only the caret should move (the selection-only branch).
f.services.events.emit(
DaemonEvent(
subsystem: 'editor',
kind: 'editor.selection-changed',
data: const {
'id': 'b_1',
'selection': {'start': 5, 'end': 5},
},
ts: DateTime.now().toUtc(),
),
);
await tester.pumpAndSettle();
final field = tester.widget<EditableText>(find.byType(EditableText));
expect(field.controller.selection.baseOffset, 5);
expect(field.controller.text, 'content of lib/a.dart'); // unchanged
});
testWidgets('tapping an inactive tab routes to editor.activate', (tester) async { testWidgets('tapping an inactive tab routes to editor.activate', (tester) async {
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1'); stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
String? activated; String? activated;
+43
View File
@@ -89,6 +89,49 @@ void main() {
expect((r.data['selection'] as Map)['start'], 8); expect((r.data['selection'] as Map)['start'], 8);
}); });
test('editor.goto-line jumps the active buffer to a 1-based line (T-407)', () async {
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
await call('editor.open', {'path': 'multi.txt'});
final r = await call('editor.goto-line', {'line': 3});
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['line'], 3);
// Line 3 starts after 'one\n' + 'two\n' = 8 characters.
final read = await call('editor.read');
expect((read.data['selection'] as Map)['start'], 8);
});
test('editor.goto-line clamps an out-of-range line to the content end', () async {
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\n'); // 8 chars
await call('editor.open', {'path': 'multi.txt'});
final r = await call('editor.goto-line', {'line': 999});
expect(r.ok, isTrue);
final read = await call('editor.read');
expect((read.data['selection'] as Map)['start'], 8);
});
test('editor.goto-line rejects a non-positive line', () async {
await call('editor.open', {'path': 'doc.md'});
final r = await call('editor.goto-line', {'line': 0});
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('editor.goto-line with no active buffer is not-found', () async {
final r = await call('editor.goto-line', {'line': 2});
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
});
test('CLI positional line binds to editor.goto-line (T-232)', () async {
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
await call('editor.open', {'path': 'multi.txt'});
final r = await call('editor.goto-line', {
'positional': ['2'],
});
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['line'], 2);
});
test('editor.insert without id targets the active buffer', () async { test('editor.insert without id targets the active buffer', () async {
await call('editor.open', {'path': 'doc.md'}); await call('editor.open', {'path': 'doc.md'});
final r = await call('editor.insert', {'text': 'X '}); final r = await call('editor.insert', {'text': 'X '});
+174
View File
@@ -0,0 +1,174 @@
/// Unit tests for the Vim ex command-line (T-407): the [parseExCommand]
/// grammar, the [ExLineController] open/close/flash state, and the
/// editor-targeted executor functions (which no-op when no buffer is active).
library;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/fake_ipc.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
void main() {
group('parseExCommand', () {
test('empty (and bare colon) is a no-op', () {
expect(parseExCommand(''), isA<ExNoop>());
expect(parseExCommand(' '), isA<ExNoop>());
expect(parseExCommand(':'), isA<ExNoop>());
});
test('write / quit / write-quit and their aliases', () {
expect(parseExCommand('w'), isA<ExWrite>());
expect(parseExCommand('q'), isA<ExQuit>());
expect(parseExCommand('q!'), isA<ExQuit>());
expect(parseExCommand('wq'), isA<ExWriteQuit>());
expect(parseExCommand('wq!'), isA<ExWriteQuit>());
expect(parseExCommand('x'), isA<ExWriteQuit>());
expect(parseExCommand('x!'), isA<ExWriteQuit>());
});
test('a leading colon is tolerated', () {
expect(parseExCommand(':w'), isA<ExWrite>());
expect(parseExCommand(':q'), isA<ExQuit>());
});
test('edit seeds quick-open with the rest of the line', () {
expect(parseExCommand('e'), const ExEdit(''));
expect(parseExCommand('e lib/main.dart'), const ExEdit('lib/main.dart'));
expect(parseExCommand('e spaced '), const ExEdit('spaced'));
});
test('a positive integer is a goto-line', () {
expect(parseExCommand('1'), const ExGoto(1));
expect(parseExCommand('42'), const ExGoto(42));
});
test('zero, negatives and junk are unknown', () {
expect(parseExCommand('0'), isA<ExUnknown>());
expect(parseExCommand('-3'), isA<ExUnknown>());
expect(parseExCommand('wat'), isA<ExUnknown>());
expect(parseExCommand('e'), isNot(isA<ExUnknown>())); // guard: e is ExEdit
});
});
group('ExLineController', () {
late ExLineController c;
setUp(() => c = ExLineController());
tearDown(() => c.dispose());
test('open clears input and flips isOpen; close resets', () {
var notifications = 0;
c.addListener(() => notifications++);
c.setInput('stale');
c.open();
expect(c.isOpen, isTrue);
expect(c.input, isEmpty);
c.setInput('w');
expect(c.input, 'w');
c.close();
expect(c.isOpen, isFalse);
expect(c.input, isEmpty);
expect(notifications, greaterThan(0));
});
test('open is idempotent and does not re-clear a second time', () {
c.open();
c.setInput('w');
c.open(); // no-op
expect(c.input, 'w');
});
test('flashInvalid bumps the nonce monotonically', () {
final start = c.invalidNonce;
c.flashInvalid();
c.flashInvalid();
expect(c.invalidNonce, start + 2);
});
});
group('executors', () {
late DaemonBus bus;
late FakeDaemonClient ipc;
late List<String> calls;
setUp(() {
bus = DaemonBus();
ipc = FakeDaemonClient(log: Logger(), events: bus);
calls = [];
});
tearDown(() => bus.dispose());
void record(String cmd, [Map<String, Object?> data = const {}]) {
ipc.stub(cmd, (a) async {
calls.add(cmd);
return _ok(data);
});
}
test(':w saves the active buffer', () async {
record('editor.save');
await exWriteActive(ipc);
expect(calls, ['editor.save']);
});
test(':q resolves the active id then closes it', () async {
ipc.stub('editor.active', (_) async {
calls.add('editor.active');
return _ok({
'active': {'id': 'b_7'},
});
});
ipc.stub('editor.close', (a) async {
calls.add('editor.close:${a['id']}');
return _ok({});
});
await exQuitActive(ipc);
expect(calls, ['editor.active', 'editor.close:b_7']);
});
test(':q is a no-op with no active buffer', () async {
ipc.stub('editor.active', (_) async => _ok({'active': null}));
record('editor.close');
await exQuitActive(ipc);
expect(calls, isEmpty); // never reached editor.close
});
test(':wq saves then closes the active tab', () async {
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_3'},
}),
);
ipc.stub('editor.save', (a) async {
calls.add('save:${a['id']}');
return _ok({});
});
ipc.stub('editor.close', (a) async {
calls.add('close:${a['id']}');
return _ok({});
});
await exWriteQuitActive(ipc);
expect(calls, ['save:b_3', 'close:b_3']);
});
test(':wq is a no-op with no active buffer', () async {
ipc.stub('editor.active', (_) async => _ok({'active': null}));
record('editor.save');
record('editor.close');
await exWriteQuitActive(ipc);
expect(calls, isEmpty);
});
test(':N dispatches editor.goto-line with the line', () async {
ipc.stub('editor.goto-line', (a) async {
calls.add('goto:${a['line']}');
return _ok({});
});
await exGotoLineActive(ipc, 42);
expect(calls, ['goto:42']);
});
});
}
+143
View File
@@ -0,0 +1,143 @@
/// Widget tests for the Vim ex command-line overlay (T-407): each v1 command
/// row dispatches the right editor IPC verb (or seeds quick-open), unknown
/// commands keep the overlay open with a hint, and Esc dismisses it.
///
/// Built on a tight, sized Stack rather than the shared `harness()` — the
/// overlay is a `Positioned` child and needs a bounded Stack ancestor (the
/// canSizeOverlay harness mis-sizes positioned content).
library;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
IpcResponse _ok([Map<String, Object?> data = const {}]) => IpcResponse.ok(id: '', data: data);
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Widget mount() => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: const MediaQuery(
data: MediaQueryData(size: Size(800, 600)),
child: SizedBox(width: 800, height: 600, child: Stack(children: [ExLineOverlay()])),
),
),
),
);
/// Open the overlay and type [text] into it (no submit yet).
Future<void> openAndType(WidgetTester tester, String text) async {
await tester.pumpWidget(mount());
f.services.exLine.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), text);
await pumpAsync(tester);
}
Future<void> submit(WidgetTester tester) async {
await tester.testTextInput.receiveAction(TextInputAction.done);
await pumpAsync(tester);
}
testWidgets('closed: renders nothing', (tester) async {
await tester.pumpWidget(mount());
expect(find.byType(EditableText), findsNothing);
});
testWidgets(':w saves the active buffer and closes', (tester) async {
var saved = false;
f.ipc.stub('editor.save', (_) async {
saved = true;
return _ok();
});
await openAndType(tester, 'w');
await submit(tester);
expect(saved, isTrue);
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets(':q closes the active tab via its id', (tester) async {
String? closed;
f.ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_9'},
}),
);
f.ipc.stub('editor.close', (a) async {
closed = a['id'] as String?;
return _ok();
});
await openAndType(tester, 'q');
await submit(tester);
expect(closed, 'b_9');
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets(':42 dispatches editor.goto-line', (tester) async {
Object? line;
f.ipc.stub('editor.goto-line', (a) async {
line = a['line'];
return _ok();
});
await openAndType(tester, '42');
await submit(tester);
expect(line, 42);
});
testWidgets(':e seeds quick-open and closes the ex-line', (tester) async {
await openAndType(tester, 'e lib/main.dart');
await submit(tester);
expect(f.services.exLine.isOpen, isFalse);
expect(f.services.quickOpen.isOpen, isTrue);
expect(f.services.quickOpen.filter, 'lib/main.dart');
});
testWidgets('unknown command keeps the overlay open and shows the hint', (tester) async {
await openAndType(tester, 'nope');
await submit(tester);
expect(f.services.exLine.isOpen, isTrue);
expect(find.text('Not an editor command'), findsOneWidget);
});
testWidgets('editing after a rejection clears the hint', (tester) async {
await openAndType(tester, 'nope');
await submit(tester);
expect(find.text('Not an editor command'), findsOneWidget);
await tester.enterText(find.byType(EditableText), 'w');
await pumpAsync(tester);
expect(find.text('Not an editor command'), findsNothing);
});
testWidgets('DismissIntent closes the overlay', (tester) async {
await openAndType(tester, 'w');
final ctx = tester.element(find.byType(EditableText));
Actions.invoke(ctx, const DismissIntent());
await pumpAsync(tester);
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets('opening publishes the exline.open scope flag; closing clears it', (tester) async {
await tester.pumpWidget(mount());
f.services.exLine.open();
await pumpAsync(tester);
expect(f.services.keymap.scope['exline.open'], isTrue);
f.services.exLine.close();
await pumpAsync(tester);
expect(f.services.keymap.scope['exline.open'], isNot(true));
});
}