diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index aaee7382..2c11a8a9 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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 ('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 ('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-.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 → quickOpen.open pre-seeded with (check QuickOpenIntent for a seed param; add one if absent), : → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), : → 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 →quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) : 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 opens quick-open seeded with , : jumps the active buffer to that line. +- : 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 today→editor.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 → quickOpen.open pre-seeded with (check QuickOpenIntent for a seed param; add one if absent), : → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), : → 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 →quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) : 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 opens quick-open seeded with , : jumps the active buffer to that line. +- : 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 today→editor.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 (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; diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index eb6da560..163ee250 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -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 ('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 ('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); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 95a3340f..609817ea 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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`). **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-.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 → quickOpen.open pre-seeded with (check QuickOpenIntent for a seed param; add one if absent), : → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), : → 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 →quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) : 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 opens quick-open seeded with , : jumps the active buffer to that line. +- : 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 today→editor.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 (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 → quickOpen.open pre-seeded with (check QuickOpenIntent for a seed param; add one if absent), : → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), : → 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 →quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) : 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 opens quick-open seeded with , : jumps the active buffer to that line. +- : 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 today→editor.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 (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); diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b59489..61a90902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### 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 ` jump to quick-open seeded with the path, `:` 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 persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`, Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so diff --git a/assets/keymaps/vim.yaml b/assets/keymaps/vim.yaml index 9975bc2a..9c610afe 100644 --- a/assets/keymaps/vim.yaml +++ b/assets/keymaps/vim.yaml @@ -50,6 +50,11 @@ bindings: - intent: dismiss keys: escape 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 keys: [ctrl+shift+f, meta+shift+f] - intent: focus.nextPanel @@ -128,6 +133,18 @@ bindings: keys: [ctrl+w q, ctrl+w c] when: "vim.normal || vim.visual" + # ---- Ex command-line (T-407) ----------------------------------------- + # `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e ` `: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 ------------------------------------------------ - intent: command:vim.mode.visual keys: v diff --git a/lib/builtin/editor/src/editor_controller.dart b/lib/builtin/editor/src/editor_controller.dart index fea8a9b3..ad8cb9c5 100644 --- a/lib/builtin/editor/src/editor_controller.dart +++ b/lib/builtin/editor/src/editor_controller.dart @@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier { _dirty = false; 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()); + notifyListeners(); + } + } case 'editor.settings-changed': // A source (e.g. a saved .editorconfig) re-resolved the buffer's // settings. Refresh the active buffer's copy so indent/ruler update. diff --git a/lib/builtin/editor/src/editor_view.dart b/lib/builtin/editor/src/editor_view.dart index 9b59ca2d..3dd336bd 100644 --- a/lib/builtin/editor/src/editor_view.dart +++ b/lib/builtin/editor/src/editor_view.dart @@ -108,12 +108,20 @@ class _EditorViewState extends State { final c = _controller!; _syncTabs(c); _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) { _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.value = TextEditingValue(text: c.content, selection: sel); _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 } @@ -274,7 +282,13 @@ class _EditorViewState extends State { } 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; if (!id.startsWith('editor.vim.')) { // Mode change (vim.mode.*) or any other command. diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 6cdd67ff..40994fbf 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -34,6 +34,7 @@ export 'src/keymap/pane_key_nav.dart'; export 'src/keymap/sequence_matcher.dart'; export 'src/keymap/when_clause.dart'; export 'src/dialog.dart'; +export 'src/ex_line.dart'; export 'src/extensions_manager.dart'; export 'src/file_open.dart'; export 'src/files.dart'; diff --git a/lib/kernel/src/ex_line.dart b/lib/kernel/src/ex_line.dart new file mode 100644 index 00000000..b5a67894 --- /dev/null +++ b/lib/kernel/src/ex_line.dart @@ -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 ` `: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 ` — open quick-open seeded with `` (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; +} + +/// `:` — jump the active buffer to 1-based line ``. +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 ` — 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 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 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 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 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}); +} + +/// `:` — jump the active buffer to 1-based [line]. `editor.goto-line` +/// resolves the active buffer server-side and clamps out-of-range lines. +Future exGotoLineActive(DaemonClient ipc, int line) async { + await ipc.request('editor.goto-line', args: {'line': line}); +} diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 52d59556..6ce48a82 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart'; import 'package:clide/kernel/src/os.dart'; import 'package:clide/kernel/src/panels/arrangement.dart'; import 'package:clide/kernel/src/panels/registry.dart'; +import 'package:clide/kernel/src/ex_line.dart'; import 'package:clide/kernel/src/project.dart'; import 'package:clide/kernel/src/quick_open.dart'; import 'package:clide/kernel/src/reader_nav.dart'; @@ -58,6 +59,7 @@ class KernelServices { required this.commands, required this.palette, required this.quickOpen, + required this.exLine, required this.recentFiles, required this.readerNav, required this.keybindings, @@ -97,6 +99,9 @@ class KernelServices { final CommandRegistry commands; final PaletteController palette; final QuickOpenController quickOpen; + + /// Transient Vim ex command-line overlay state (T-407). + final ExLineController exLine; final RecentFilesService recentFiles; final ReaderNavRegistry readerNav; final KeybindingResolver keybindings; @@ -167,6 +172,7 @@ class KernelServices { final palette = PaletteController(commands); final recentFiles = RecentFilesService(); final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths); + final exLine = ExLineController(); final readerNav = ReaderNavRegistry(messages); final clipboard = ClideClipboard(); final files = FileServices(events); @@ -253,6 +259,7 @@ class KernelServices { commands: commands, palette: palette, quickOpen: quickOpen, + exLine: exLine, recentFiles: recentFiles, readerNav: readerNav, keybindings: keybindings, @@ -286,6 +293,7 @@ class KernelServices { commands.dispose(); palette.dispose(); quickOpen.dispose(); + exLine.dispose(); toast.dispose(); recentFiles.dispose(); readerNav.dispose(); diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart index 5c9396df..d6061679 100644 --- a/lib/kernel/src/keymap/intents.dart +++ b/lib/kernel/src/keymap/intents.dart @@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent { const QuickOpenAcceptIntent(); } +// -- Vim ex command-line (T-407) -------------------------------------------- + +/// Open the Vim ex command-line overlay (`:`). A typed intent (not a +/// `command:` bridge) so it survives the editor's command-mode matcher and a +/// focused pane's nav matcher, both of which bubble unhandled typed intents to +/// the app-root Actions where this resolves to `services.exLine.open()`. +class ExLineOpenIntent extends Intent { + const ExLineOpenIntent(); +} + +/// Save the active buffer and close its tab (`ZZ`), without opening the +/// overlay — shares the `:wq` execution path. +class ExLineWriteQuitIntent extends Intent { + const ExLineWriteQuitIntent(); +} + // -- Find in files ---------------------------------------------------------- /// Reveal the find-in-files search panel in the sidebar. @@ -193,6 +209,8 @@ final Map builtinIntents = { 'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(), 'findInFiles.open': () => const FindInFilesIntent(), + 'exline.open': () => const ExLineOpenIntent(), + 'exline.writeQuit': () => const ExLineWriteQuitIntent(), // Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc. 'nav.down': () => const NavDownIntent(), 'nav.up': () => const NavUpIntent(), diff --git a/lib/kernel/src/keymap/pane_key_nav.dart b/lib/kernel/src/keymap/pane_key_nav.dart index 77be4e14..73454d38 100644 --- a/lib/kernel/src/keymap/pane_key_nav.dart +++ b/lib/kernel/src/keymap/pane_key_nav.dart @@ -102,9 +102,16 @@ class _PaneKeyNavState extends State { switch (r.outcome) { case SeqOutcome.fired: // The vim preset also binds these keys to editor.vim.* motions; in a - // pane only nav.* applies. A non-nav fired intent (e.g. a stray - // editor.vim.* with no focus guard) is swallowed, never executed here. - if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count); + // pane only nav.* applies, and editor.vim.* (an InvokeCommandIntent) is + // swallowed — never run buffer edits from a pane. A typed *app* intent + // (e.g. the ex-line `:` / ZZ) bubbles to the app-root Actions for its + // global handler (T-407). + final fired = r.intent; + if (fired is NavIntent) { + widget.onNav(fired, r.count); + } else if (fired != null && fired is! InvokeCommandIntent) { + Actions.maybeInvoke(context, fired); + } return KeyEventResult.handled; case SeqOutcome.pending: return KeyEventResult.handled; diff --git a/lib/kernel/src/quick_open.dart b/lib/kernel/src/quick_open.dart index 7681fdcf..a595ad62 100644 --- a/lib/kernel/src/quick_open.dart +++ b/lib/kernel/src/quick_open.dart @@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier { return _selectedIndex.clamp(0, n - 1); } - void open() { + /// Open the picker. An optional [seed] pre-fills the filter — used by the + /// ex-line `:e ` command (T-407) to jump straight to a query. + void open({String? seed}) { if (_open) return; _open = true; - _filter = ''; + _filter = seed ?? ''; _selectedIndex = 0; notifyListeners(); } diff --git a/lib/src/daemon/editor_commands.dart b/lib/src/daemon/editor_commands.dart index 202c211f..92cf5434 100644 --- a/lib/src/daemon/editor_commands.dart +++ b/lib/src/daemon/editor_commands.dart @@ -3,7 +3,7 @@ /// Verb list matches CLAUDE.md's tier-2 surface: /// editor.open editor.active editor.activate editor.insert /// 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 /// 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.save', (req) => _save(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( @@ -220,3 +228,19 @@ Future _close(IpcRequest req, EditorRegistry r) async { r.close(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 `. +/// Reuses the [_offsetForLine] mapping `editor.open --line` uses; out-of-range +/// lines clamp to the buffer end via setSelection. +Future _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}); +} diff --git a/lib/src/shell/root_shell.dart b/lib/src/shell/root_shell.dart index 7a40a986..6fd95470 100644 --- a/lib/src/shell/root_shell.dart +++ b/lib/src/shell/root_shell.dart @@ -120,6 +120,19 @@ class RootShellState extends State { return null; }, ), + ExLineOpenIntent: CallbackAction( + onInvoke: (_) { + widget.services.exLine.open(); + return null; + }, + ), + ExLineWriteQuitIntent: CallbackAction( + onInvoke: (_) { + // ZZ — save+close the active tab without opening the overlay. + unawaited(exWriteQuitActive(widget.services.ipc)); + return null; + }, + ), FindInFilesIntent: CallbackAction( onInvoke: (_) { widget.services.arrangement.setVisible(Slots.sidebar, true); @@ -160,6 +173,7 @@ class RootShellState extends State { const Positioned.fill(child: RootLayout()), const ClidePalette(), const QuickOpenOverlay(), + const ExLineOverlay(), const Positioned.fill(child: _WelcomeOverlay()), const ToastOverlay(), ], diff --git a/lib/widgets/src/ex_line_overlay.dart b/lib/widgets/src/ex_line_overlay.dart new file mode 100644 index 00000000..0ec05eab --- /dev/null +++ b/lib/widgets/src/ex_line_overlay.dart @@ -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 createState() => _ExLineOverlayState(); +} + +class _ExLineOverlayState extends State { + 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 _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: >{DismissIntent: CallbackAction(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), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 4ca25380..29741692 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -14,6 +14,7 @@ export 'src/clide_collapser_card.dart'; export 'src/clide_code_block.dart'; export 'src/clide_divider.dart'; export 'src/clide_file_image.dart'; +export 'src/ex_line_overlay.dart'; export 'src/clide_filter_box.dart'; export 'src/clide_lightbox.dart'; export 'src/clide_markdown.dart'; diff --git a/test/builtin/editor/editor_controller_test.dart b/test/builtin/editor/editor_controller_test.dart index f2893405..86c5f0c1 100644 --- a/test/builtin/editor/editor_controller_test.dart +++ b/test/builtin/editor/editor_controller_test.dart @@ -187,6 +187,59 @@ void main() { 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 { ipc.stub( 'editor.list', diff --git a/test/builtin/editor/editor_view_test.dart b/test/builtin/editor/editor_view_test.dart index 5ef7da9c..166b3f01 100644 --- a/test/builtin/editor/editor_view_test.dart +++ b/test/builtin/editor/editor_view_test.dart @@ -6,6 +6,7 @@ library; import 'package:clide/builtin/editor/src/editor_view.dart'; 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'; @@ -76,6 +77,32 @@ void main() { 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(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(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 { stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1'); String? activated; diff --git a/test/daemon/editor_commands_test.dart b/test/daemon/editor_commands_test.dart index 0f0b1c19..2f19bb59 100644 --- a/test/daemon/editor_commands_test.dart +++ b/test/daemon/editor_commands_test.dart @@ -89,6 +89,49 @@ void main() { 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 { await call('editor.open', {'path': 'doc.md'}); final r = await call('editor.insert', {'text': 'X '}); diff --git a/test/kernel/src/ex_line_test.dart b/test/kernel/src/ex_line_test.dart new file mode 100644 index 00000000..aceea09b --- /dev/null +++ b/test/kernel/src/ex_line_test.dart @@ -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 data) => IpcResponse.ok(id: '', data: data); + +void main() { + group('parseExCommand', () { + test('empty (and bare colon) is a no-op', () { + expect(parseExCommand(''), isA()); + expect(parseExCommand(' '), isA()); + expect(parseExCommand(':'), isA()); + }); + + test('write / quit / write-quit and their aliases', () { + expect(parseExCommand('w'), isA()); + expect(parseExCommand('q'), isA()); + expect(parseExCommand('q!'), isA()); + expect(parseExCommand('wq'), isA()); + expect(parseExCommand('wq!'), isA()); + expect(parseExCommand('x'), isA()); + expect(parseExCommand('x!'), isA()); + }); + + test('a leading colon is tolerated', () { + expect(parseExCommand(':w'), isA()); + expect(parseExCommand(':q'), isA()); + }); + + 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()); + expect(parseExCommand('-3'), isA()); + expect(parseExCommand('wat'), isA()); + expect(parseExCommand('e'), isNot(isA())); // 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 calls; + + setUp(() { + bus = DaemonBus(); + ipc = FakeDaemonClient(log: Logger(), events: bus); + calls = []; + }); + tearDown(() => bus.dispose()); + + void record(String cmd, [Map 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']); + }); + }); +} diff --git a/test/widgets/src/ex_line_overlay_test.dart b/test/widgets/src/ex_line_overlay_test.dart new file mode 100644 index 00000000..326bac3e --- /dev/null +++ b/test/widgets/src/ex_line_overlay_test.dart @@ -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 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 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 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)); + }); +}