Files
clide/.pql/changelog/tickets/2026-06.sql
T
jpmschweitzerandClaude Opus 4.8 198c8b18b5 feat(i18n): i18n key fields on command + settings contributions (T-462)
Lets manifest labels (command-palette/menu titles, settings labels) localize,
not just displayed widget strings. Adds optional titleKey/i18nNamespace to
CommandContribution and labelKey/helpKey/titleKey + a category i18nNamespace to
the settings schema. The command palette and menu bar now resolve titles via a
shared localizedCommandTitle helper — and the palette's fuzzy search matches
the localized title too (PaletteController.titleResolver). No behaviour change
until the per-extension keys + catalog entries land (placeholder == English).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:44:35 +02:00

7369 lines
1.8 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ('06FB0TNQM7MFNNCC0MMY2TCNRM', 'bug', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Markdown reader 404s on absolute paths handed to files.read', 'The sidebar markdown reader (and any files.read caller) was handed an absolute path (e.g. the Claude Config tab publishing a skill''s absolute SKILL.md manifestPath). resolveUnderRoot joined the absolute path onto the workspace root (/repo + /repo/x = /repo/repo/x), which does not exist, so the reader showed ''file not found'' for a file that exists. Fix: resolveUnderRoot normalizes an absolute input as-is and relies on the existing containment check (so absolute-under-root resolves, absolute-outside still throws PathOutsideRoot). Found live opening .claude/skills/frame0-wireframe/SKILL.md.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 06:49:25', '2026-06-01 06:49:47', NULL, '22ea906d212d808ba7af9cb83060b195', 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 ('06FB0TNQM4M2Z9YZ0SA81E5Q08', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Reader can''t open user-scope Claude config files (~/.claude skills/agents/commands)', 'Clicking a user-scope skill in the Claude Config tab (e.g. /home/<user>/.claude/skills/peon-ping-toggle/SKILL.md) shows ''path outside workspace''. The markdown reader loads via files.read, which is workspace-confined for security (T-102); but user-scope Claude config files legitimately live under ~/.claude, outside the repo. Per D-76, Claude''s config surface (skills/agents/commands under ~/.claude AND the repo .claude) is clide-managed and safe to surface. Proposed: a scoped trusted-read — a new IPC verb (e.g. claude.read-config-file) that permits reads ONLY under the resolved Claude config roots (~/.claude + <repo>/.claude), still rejecting arbitrary paths. Route the Config tab''s skill/agent/command markdown clicks through it, or have the markdown reader fall back to it when the path is under a Claude config root but outside the workspace. Alternative: the Config tab already reads SKILL.md during scan (claude_config.dart) and could publish content directly via a load-by-content channel rather than a path (the reader currently only reads by path). Repo-scope skills already work after T-194; this covers the user-scope half.', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 06:56:05', '2026-06-01 07:08:16', NULL, '12a1aa9958f3b375c19a5c03f694fc00', 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 ('06FB0TNQM6NTYAB8K6T50F8W00', 'bug', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Decision reader: first click switches tab but doesn''t load (lost on tab mount)', 'Clicking a D-record activates the decisions.detail context-panel tab but does not load the record; only the SECOND click (any record) works. Root cause: DecisionDetailView subscribes to the ''selection'' MessageBus channel in didChangeDependencies, which runs only AFTER the extension''s activateTab mounts the view. The bus is broadcast (no replay), so the ''selection'' message that triggered the reveal is already gone — the view subscribes too late and misses it. The markdown extension already solved this: activateTab, then addPostFrameCallback -> re-publish on a separate ''load'' channel the viewer subscribes to, so the load fires after the viewer mounts+subscribes. Fix: mirror that — DecisionDetailView subscribes to ''load''; DecisionsExtension forwards ''selection'' -> post-frame ''load'' after activateTab.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:10', '2026-06-01 10:45:14', NULL, '818668b41eab83cdb411ccd17195c5c1', 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 ('06FB0TNQM6HQXNNF05GXWK7BWM', 'task', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Reader pin UX: pin icon, toggle, left-toggle/right-navigate', 'Polish on the reader action bar (follows T-190). (1) push-pin glyph instead of the chain/link icon; (2) the pin button TOGGLES pinned state (tap to pin current, tap again to unpin) rather than set/replace; (3) layout split — pin/unpin toggle on the LEFT, jump-to-pin moved into the navigator (back/forward) on the right. Added ReaderNav.togglePin(), PhosphorIcons.pushPin (0xe3e2) + arrowUUpLeft (0xe08a), and an active state on the action button.', 'done', 'low', NULL, NULL, NULL, '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'aef79fd9dbfa2f9197f450552ce72377', 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 ('06FB0TNQM68APBCQTMGJAZ2B9C', 'task', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Tickets detail: adopt retained ReaderNav (back/forward + pin) + static tab', 'Bring the tickets detail reader in line with markdown/decisions (T-196/D-81). Adopt the retained ReaderNav(''builtin.tickets'', dataKey ''id''): the nav records selections + emits ''load''; the controller loads on ''load'' (not ''selection''); the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar (pin toggle left; back/forward + jump-to-pin right; NO edit pencil since tickets are pql records, not files). Also drop the per-click uncontribute/contribute churn in TicketsExtension (the T-188 anti-pattern) — use the static tickets.detail tab + reveal on selection. TicketDetailController loses its panels dependency (reveal moves to the extension).', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 11:38:32', '2026-06-01 12:35:04', NULL, '999c432fcd16538d949eecc86bb61a05', 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 ('06FB0TNQM4RAKSX59W1HJCZ1M8', 'bug', NULL, 'Sidebar icon rail overflows (RenderFlex) when tab count exceeds width', 'Adding the Search sidebar tab (T-52) pushed the bottom icon rail past its width: ''A RenderFlex overflowed by 54 pixels'' from clide_icon_rail.dart — a fixed Row(center, max) with one _RailButton per tab. Fix: ClideIconRail centers when the icons fit and scrolls horizontally when they don''t (LayoutBuilder + SingleChildScrollView + ConstrainedBox(minWidth: maxWidth)). Robust to any tab count. (Related: T-201 will reduce tab count by merging the pql + find-in-files search surfaces.)', 'done', 'high', NULL, NULL, NULL, '2026-06-01 14:04:00', '2026-06-01 14:04:24', NULL, '5c3606e7985e8621109b0f8ca2e5cbe7', 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 ('06FB0TNQM57GY6PTG78SD3961M', 'task', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Merge pql search into the find-in-files Search tab (one search surface)', 'The pql sidebar panel (ranked text search + PQL DSL query + synced markdown-file listing) and the find-in-files Search tab (content grep) are duplicate search surfaces. Consolidate into ONE Search tab with a mode switch (option B): Find (content grep, existing), Vault (pql ranked search), Query (PQL DSL), Markdown (synced md-file listing, keeps focus-highlight + live refresh). Remove the builtin.pql SIDEBAR tab; keep the pql extension''s Backlinks context-panel tab + PqlController. Implementation: SearchPanelView gains a 4-mode switcher and holds both FindInFilesController + PqlController; extract the pql body/result rows into a reusable widget (they''re private in pql_panel_view.dart today); move the md-listing focus-sync + files.changed refresh into the merged panel; delete PqlPanelView. Test/coverage note: pql_panel_view + pql_controller are currently untested (not in the coverage denominator); folding them into the tested Search panel pulls them in, so the merge must cover the Vault/Query/Markdown modes too (same dynamic that bit T-199). Also reduces sidebar tab count (helps the rail, T-200). No D-79 conflict: grep vs ranked-search remain distinct backends; this is UI consolidation only.', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 14:25:59', '2026-06-01 14:45:39', NULL, '9ae4d2691295abc84d8c3489ca1e3825', 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 ('06FB0TNQM7G8QBJ2PNQ0VREN24', 'bug', NULL, 'Editor pane never reveals on editor.open (edit button, file-tree open)', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race.
Reopened fix (2026-06-01): the original fix called panels.activateTab(Slots.workspace, ''editor.active''), but _WorkspaceSlot renders its editor split off arrangement.editorOpen, NOT the active tab — so the editor never appeared. Real fix: EditorExtension.activate now calls arrangement.openEditor() on editor.opened / active-changed(non-null) and closeEditor() on active-changed(null). Test asserts arrangement.editorOpen.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:19', '2026-06-01 16:36:38', NULL, '7c752b0b9c4b83d17bcf896dfc6b15e4', 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 ('06FB0TNQM5A3984KFPD4NKRG6M', 'bug', NULL, 'rate_limit_event with numeric resetsAt crashes the Claude session', 'stream_json_session._statusFromEvent cast info[''resetsAt''] as String?, but claude sends resetsAt as a unix-epoch NUMBER → ''int is not a subtype of String?'' unhandled in _onLine, killing the session''s line parsing. Fix: accept num (epoch seconds → DateTime.fromMillisecondsSinceEpoch) or String (ISO). Surfaced in a live session log.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '1748dedeac6b6e160d609c3336488687', 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 ('06FB0TNQM643F7Z648KNFPT7GR', 'bug', '06FB0TNQM7G8QBJ2PNQ0VREN24', 'Editor split throws semantics + SelectableRegion exceptions on open', 'Opening the editor split (now that T-197 works) flooded two exceptions. (1) The _EditorDragHandle Semantics had slider:true + value + onIncrease/onDecrease but no increasedValue/decreasedValue → ''SemanticsNode with action increase needs both value and increasedValue'' on every flush; added increasedValue/decreasedValue. (2) Reparenting the Claude pane (direct child → Column/Expanded) when the split opens disposed its ClideSelectionArea/SelectableRegion subtree mid selection-update microtask → ''selectable not in this registrar'' + ''renderObject of inactive element''; fixed with a stable GlobalKey on the workspace primary so Flutter moves the element instead of disposing it.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '1c5aeeaaf738cf15cbb4b1ef3b2f57fe', 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 ('06FB0TNQM65R8CNFNE1MA5GGR0', 'task', '06FB0TNQM5V7GHH42RHV04DPTR', 'Fix dead default keymap: focus.next/previous undefined drops whole preset', 'The keymap loader catches FormatException from preset parsing and silently sets _preset=null (keymap_service.dart load()). default.yaml binds tab->focus.next and shift+tab->focus.previous, but neither id exists in builtinIntents (only focus.nextPanel/previousPanel). parseIntentId returns null -> KeymapLayer.fromYaml throws -> the ENTIRE default preset is dropped at boot. So palette (ctrl+shift+p), quick-open (ctrl+p), find-in-files, and text-scale bindings are all dead at runtime. Uncaught because every keymap_service_test injects a synthetic bundle; the real asset is never parsed. Fix: add focus.next->NextFocusIntent and focus.previous->PreviousFocusIntent (Flutter-provided, like activate/dismiss) to builtinIntents so Tab does correct widget focus traversal; add a test that loads the REAL assets/keymaps/default.yaml and asserts it parses with the expected binding count, as a regression guard for every shipped preset.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:19', '2026-06-01 18:51:08', NULL, '4c8a15d72af7462d3c444373e7e1bdcb', 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 ('06FB0TNQM6M83TTXHRXESFZY14', 'story', '06FB0TNQM5V7GHH42RHV04DPTR', 'Vim mode service + status-bar mode indicator', 'A small mode-tracking service (ChangeNotifier) that owns the current Vim mode (normal/insert/visual), publishes vim.normal / vim.insert / vim.visual scope flags via KeymapService.setScopeFlag, and exposes the mode to the editor (T-206) and a status-bar indicator (-- NORMAL -- / -- INSERT -- / -- VISUAL --). Handles transitions: Esc -> normal (from any mode); i/a/o/I/A -> insert (from normal); v -> visual (from normal); colon -> command-line indicator (stretch). Resets to normal on editor focus by default (configurable). The when-clause grammar already supports dotted flags (vim.normal && editor.focused), confirmed in when_clause.dart. Service is a pure ChangeNotifier with no deps; fits in lib/kernel/src/keymap/ or as an editor builtin piece. Acceptance: setPreset(''vim'') + this service produce correct mode transitions; a regression test asserts i->insert and Esc->normal flip the scope flags; the status bar reflects mode.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:47', '2026-06-01 18:59:19', NULL, 'e0e01b13b1d2cc833f4f1ef79aa1663e', 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 ('06FB0TNQM4PH1J6ENYT4PJZRHG', 'story', '06FB0TNQM5V7GHH42RHV04DPTR', 'Key-sequence resolution in the keymap layer (Vim motions, counts)', 'The resolver is single-chord: KeyChord.fromKeyEvent -> Keymap.resolve fires and forgets one keystroke. Real Vim needs multi-key sequences (dd, gg, dw, ciw, yy) and count prefixes (5j, 3dd). Add a pending-sequence model to KeymapService: accumulate chords into a buffer, match against sequence bindings by prefix, fire on full match, reset on no-prefix-match or timeout. Add a KeySequence type (ordered list of KeyChord) alongside KeyChord. Extend the YAML schema to express sequences distinctly from alias-lists: keys:[d,d] today means ''either d or d'' (alias) — need a ''sequence:'' key or a chord-string notation (e.g. ''d d'' space-separated) for ordered sequences. Add count-prefix capture so a leading digit run is parsed as a repeat count passed to the intent. Keep single-chord resolution unchanged (fast path). This also unblocks JetBrains shift+shift (T-66) via a double-tap special-case. Decision-worthy: the sequence/notation choice may warrant a D-record.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 19:12:01', NULL, 'b615f886c304b96668db19a2a295cbd2', 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 ('06FB0TNQM5REFZXMY8ESWN0Z90', 'story', '06FB0TNQM5V7GHH42RHV04DPTR', 'Modal editor: Vim motion & edit intents', 'The editor (lib/builtin/editor/src/editor_view.dart) is a raw Flutter EditableText that only handles Cmd/Ctrl+S; in normal mode, letter keys must NOT insert text and must instead drive motions/edits. Add typed editor Intents + Action handlers wired into the editor that operate on the SyntaxTextController/TextEditingValue: cursor motions (cursorLeft/Down/Up/Right for h/j/k/l), word motions (wordForward/wordBackward/wordEnd for w/b/e), line motions (lineStart/lineEnd/firstNonBlank for 0/$/^), document motions (documentStart/End for gg/G), edits (deleteChar x, deleteLine dd, deleteWord dw, changeLine cc, changeWord cw, yankLine yy, paste p/P, openLineBelow/Above o/O), and insert-entry (enterInsert i/a/I/A/o). Motions+edits must compose with the count prefix (T-205) and operator+motion (dw, cw). The editor must read the active Vim mode (T-206 service) to decide whether a bare key inserts or commands — key interception must win over EditableText''s text input in normal mode. Acceptance: each intent has an Action that mutates the buffer correctly; regression tests cover j (cursor down), x (delete char), dd (delete line), i (enter insert).', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 19:27:18', NULL, '44c8001114ba7c63d5d2ab6ba13223a6', 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 ('06FB0TNQM48QJV4N7J62436GC0', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'Regression test: clideVersion matches pubspec version', 'Gap 6 version-drift guard. clideVersion in lib/src/build_info.g.dart (generated by gen-build-info) currently matches pubspec.yaml `version:` (both 2.1.0). Add a test asserting they stay equal so an agent keying off `clide version` is never misled.', 'done', 'low', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, '1a10d96abef4a38149f02ec60b4a2d5d', 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 ('06FB0TNQM4C2PSN2GVJY9JDSZG', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'Build the C clide client by default', 'Make the `clide-cli` target (Makefile:284) a dependency of `build` (Makefile:169) and `install` (Makefile:195) so the C client is always produced. Today it builds only via the explicit, non-default `make clide-cli`. Acceptance: a plain `make build` / `make install` produces the C client binary for the current OS.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, '8d614f61e2893184e1e6792acf92c64b', 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 ('06FB0TNQM7171NRB1T82TGP5RG', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'install delivers the C client to PATH on macOS and Linux', 'Fix `make install` so $(INSTALL_DIR)/clide (default ~/.local/bin/clide) IS the compiled C client, not the Flutter GUI binary. Today Makefile:200 symlinks the GUI bundle runner on both platforms. Acceptance: after `make install`, `which clide` resolves and `clide ping` returns {"pong":true} against a running instance on macOS and Linux. Blocked by ''Build the C client by default''.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, 'a1978f7f0fec1c0b0c0bd4b7d1f8f65e', 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 ('06FB0TNQM54WTNQDMYT0AVR4WM', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'Decide the dogfood agent model (governance Q/D record)', 'Gap 5 from self-analysis.md — foundational. Two distinct ''Claude inside clide'' stories with different gaps: (A) the clide-HOSTED stream-json session (D-77/D-78) — the user''s primary Claude pane, which itself needs `clide` on PATH to drive the surrounding IDE; (B) an EXTERNAL agent (e.g. a Claude Code harness, TERM_PROGRAM=zed as observed) driving via `clide ...` IPC, which clide cannot fully observe because plain file reads / `make test` / `git` bypass clide. Decide the intended model — hosted, external, or both — and record it under governance/ (claim via `pql decisions claim`). Gates how Epic B bootstraps and how Epic C frames observability.
Resolved (2026-06-03): recorded as D-83. Decision — clide commits to BOTH agent models with the clide-HOSTED stream-json session (D-77/D-78) as the PRIMARY dogfood target (the one clide spawns, so the one Epic B/T-214 bootstraps: CLIDE_SOCK/CLIDE_WORKSPACE + PATH + context note + Bash(clide *) allow rule), and the EXTERNAL CLI driver (D-68) as a first-class but SECONDARY, best-effort integration (manual install via T-212; no promise to observe its non-clide tool use). Epic C/T-218 parity is scoped to clide''s own surfaces reflected through the CLI in both directions; an external agent''s side-channel reads/tests/git are out of parity scope.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-02 18:13:52', '2026-06-03 09:05:50', NULL, 'f7b7cda4651e06371d642fe86313466a', 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 ('06FB0TNQM51CNEJP52P79RDVJW', 'task', '06FB0TNQM78J2H2S0R79SSSPW8', 'Inject an agent context note describing the CLI surface + parity contract', 'Give the hosted agent a short note (CLAUDE.md fragment / system reminder) stating it is inside clide and how to drive the editor / git / file tree / panes via `clide ...`, plus the D-6 parity contract. Acceptance: a hosted agent is told, without the user having to explain it.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '1a21a4b790c9cbb2c45c26a87688ec22', 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 ('06FB0TNQM7WYF8ESBSGAQF1S48', 'task', '06FB0TNQM78J2H2S0R79SSSPW8', 'Pre-seed a Bash(clide *) permission allow rule for hosted agents', 'So the hosted agent is not prompted on every `clide ...` call. Acceptance: clide-hosted sessions get a Bash(clide *) allow rule in their effective settings.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '5426b5365b2d54a9ad55cc5338f748c6', 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 ('06FB0TNQM4TP6KXTB1GHKBW1G4', 'task', '06FB0TNQM78J2H2S0R79SSSPW8', 'Export CLIDE_SOCK/CLIDE_WORKSPACE + ensure clide on the child PATH', 'When clide spawns/hosts an agent (the stream-json session per D-77, and any launched agent), inject CLIDE_SOCK (the per-workspace deterministic socket, D-70) and CLIDE_WORKSPACE into the child env, and guarantee `clide` is on the child PATH. Acceptance: a hosted agent''s shell has both vars and `clide ping` works with zero manual socket discovery. Blocked by Epic A delivery.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, 'fa6e599cfdd6165043ab54427f16d298', 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 ('06FB0TNQM78J2H2S0R79SSSPW8', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Bootstrap the clide-hosted/launched agent', 'Gap 2 from self-analysis.md. Even with the binary installed, a fresh session has no signal it is hosted by clide. There IS an MCP discovery lock (~/.claude/ide/<pid>.lock pointing at workspace + SSE URL) but NOTHING for the Bash/CLI path: no CLIDE_SOCK/CLIDE_WORKSPACE env var, no injected context note, no pre-seeded Bash(clide *) allow rule. Discovery is the difference between a capability existing and getting used — the probing agent had to be told ''you''re inside clide'' and then hunt for the socket.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-03 09:18:38', NULL, 'f6131db7ead26edd61619c18cc816e72', 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 ('06FB0TNQM7XZQJAK31WRVQ0HGW', 'bug', '06FB0TNQM68T660K07YH3X5DH0', 'Composer draft lost when the Claude pane UI changes', 'Text typed into the Claude composer is lost whenever the pane UI changes underneath it. The clearest trigger: a permission prompt / AskUserQuestion arrives and the interaction zone replaces the composer; when it resolves and the composer returns, the half-typed message is gone. It can also drop on any pane rebuild/refresh (new transcript message, status change, tab re-render) if the draft lives only in ephemeral widget State.
Make the draft persistent: an unsent composer message must survive UI changes and come back intact.
Likely cause: the composer''s TextEditingController lives in widget State that is disposed/recreated when the pane rebuilds or the interaction zone swaps the composer out. The draft (text + selection) needs to be hoisted somewhere that outlives the widget.
Acceptance:
1. Type in the composer, trigger a permission prompt (composer replaced by the interaction zone), resolve it -> the composer returns with the typed text and caret intact.
2. The draft survives an arbitrary Claude-pane rebuild (incoming transcript message, status update, sub-tab switch) without loss.
3. Draft is per-session: switching between Claude sessions/panes preserves each session''s own unsent text; sending or explicitly clearing empties it.
4. Selection/caret position preserved, not just the string (nice-to-have).
Implementation sketch: store the draft (text + selection) on the per-session model/controller (kernel- or session-scoped), not in the composer widget''s State. Seed the TextEditingController from it on mount and write back on change, so a remount after the interaction zone closes restores it. Relates to T-227 (focusing the composer) and the interaction-zone behavior where prompts replace the composer rather than rendering inline.', 'done', 'high', NULL, NULL, NULL, '2026-06-03 09:49:35', '2026-06-03 10:02:02', NULL, '63ae2f059c866728f67f7009b420657b', 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 ('06FB0TNQM7SA9TEX5CNMTG67MW', 'task', '06FB0TNQM68T660K07YH3X5DH0', 'Composer prompt history via Up/Down (Claude-CLI-style)', 'Arrow Up/Down in the Claude composer should scroll through previously-sent prompts, matching the Claude Code CLI UX:
- Up: if the caret isn''t on the first visual line, move up one line (normal editing); once on the first line, Up steps to the previous prompt in history. Down mirrors it (move down within multiline; at the last line, step to the next/newer entry).
- When entering history, stash the current draft as the newest entry so Down past the most recent restores exactly what the user was typing.
- History is the sequence of prompts submitted from this composer (this session at least; persisting across restarts is a nice-to-have).
- Plays well with the existing Enter-submit / Shift+Enter-newline and the slash typeahead (Up/Down already drive the typeahead when it''s open — history nav only applies when the typeahead is closed).
Implementation: a small history controller + caret-line detection in ClaudeComposer (EditableText); the Up/Down handling lives alongside the existing _onKey/Shortcuts. Acceptance: send a few prompts, Up walks back through them, Down returns through them to the restored draft; multiline prompts navigate line-by-line before crossing into history; widget tests for the bound conditions (first/last line) and draft stash/restore.', 'done', 'medium', NULL, NULL, NULL, '2026-05-24 09:42:44', '2026-06-03 10:05:55', NULL, '4d0babaecb6b5cb3f77222e9ed786f3b', 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 ('06FB0TNQM5PFAYE4XH7WV6VDW8', 'story', '06FB0TNQM68T660K07YH3X5DH0', 'Click empty Claude-pane area to focus the composer', 'Clicking anywhere in the Claude pane that is NOT an interactive control (copy button, approve/reject, AskUserQuestion options, links, selectable transcript text, scrollbars, tabs) should move keyboard focus to the composer text field at the bottom. Today focusing the composer requires precisely clicking the field; a click on the conversation background does nothing, so the input is fiddly to focus.
Goal: make the composer easy to focus — a click on dead space in the pane lands the cursor in the input, like clicking an empty area of a chat app focuses its reply box.
Acceptance:
1. A tap on non-interactive Claude-pane background focuses the composer input (cursor ready to type).
2. Taps on actual controls keep their behavior (copy, approve/reject, AskUserQuestion choices, tab strip, links) — the focus-grab must not swallow or pre-empt their gestures.
3. Text selection in the transcript still works: a click-drag to select must NOT steal focus to the composer (only a plain tap/click-release with no selection focuses it).
4. Interaction-zone nuance (D-78): when a permission / AskUserQuestion prompt has replaced the composer in the interaction zone, a background click should focus that prompt''s primary control instead of the (absent) composer — or do nothing — never resurrect the composer.
Implementation sketch: wrap the pane body in a tap recognizer (behavior: translucent / deferToChild) that, on a tap that did not hit a child gesture and produced no text selection, requests focus on the composer FocusNode. Mind the SelectableRegion so selection drags aren''t hijacked.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 09:48:23', '2026-06-03 10:09:26', NULL, '68310a50972a774abfeadd32f221a140', 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 ('06FB0TNQM68T660K07YH3X5DH0', 'epic', NULL, 'Claude composer ergonomics: focus, persistence, and history', 'Make the Claude composer (the prompt input at the bottom of the Claude pane) feel effortless to use. Groups the input-box ergonomics work:
- T-227 — click empty pane area to focus the composer
- T-228 — preserve the in-progress draft across UI changes (permission prompts etc.)
- T-163 — Up/Down arrow recall of prompt history (Claude-CLI-style), triggered when the caret hits the top/bottom edge of the field
Theme: the composer should be easy to focus, never lose what you typed, and let you recall and re-send prior prompts the way the Claude CLI does. These are independent leaves that share the composer surface and per-session draft/history state, so they''re worth coordinating.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 09:51:28', '2026-06-03 10:09:26', NULL, 'ec5d2dcaf4e33b2d4dde1b3d4623110a', 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 ('06FB0TNQM6Y5SET5WGSS2W78F8', 'task', '06FB0TNQM7AMEYEBTF8YGJ4BVG', 'Register built-in panes into the PaneRegistry the CLI reads', 'Make the kernel layout''s TabContribution panes (Claude / Files / Editor and any viewer) visible to `pane list` / `pane focus` by registering or mirroring them into PaneRegistry (lib/src/panes/registry.dart, read by pane_commands.dart:121). Acceptance: with the GUI open, `clide pane list` enumerates the live panes the user sees, with stable ids and focus state.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 10:31:10', NULL, '697f1f648cd46893ac5863c139ba4bee', 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 ('06FB0TNQM6EGSD0SB7NYNSP1BM', 'task', '06FB0TNQM7AMEYEBTF8YGJ4BVG', 'Reflect active editor buffer + focused file + selection into EditorRegistry', 'For UI-driven (non-IPC) opens and focus changes, mirror the active file + selection into EditorRegistry (lib/src/editor/registry.dart, read by editor_commands.dart:120/136) so `editor active` / `editor list` track what the user is actually looking at, not only IPC-opened buffers. Acceptance: focusing/opening a file in the GUI is reflected in `clide editor active`.
Resolved (2026-06-03): done by prior editor work, not new code. Verified by reading the flow: openWorkspaceFile (file_open.dart) routes non-.md opens through the editor.open IPC verb -> EditorRegistry.open -> _setActive, so file-tree/quick-open opens already populate ''editor active''/''editor list''; tab switches go through editor.activate; and editor_view._onTextChanged pushes editor.set-content on caret-only moves too (its guard skips only when BOTH text and selection are unchanged), so selection is reflected. The probe saw editor active=null only because no code file was open (just the Claude pane / a markdown reader) -- correct, not a gap. The genuine remainder -- the read-only markdown/decisions READER''s currently-viewed file (routed via the D-81 ReaderNav bus, not editor.open, and rightly NOT an EditorRegistry buffer) -- is folded into T-221 (clide status: focused file + selection). Editor-reflects-opens claim is code-read, to be confirmed on the next live run.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 10:59:25', NULL, '178db75de081136eebacc44488a52397', 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 ('06FB0TNQM67ET3VQP90WRXVSMG', 'task', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).
Verified done (2026-06-03): the cockpit per-agent permission-mode cycle badge is implemented and wired in claude_meta_sidebar.dart — _PermissionModeBadge (safe cycle default->acceptEdits->plan, Shift-click bypass behind a confirm, live mode from SessionStatus). No further work; closing. The primary-pane equivalent is T-226.', 'done', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-06-03 11:41:17', NULL, 'ccaa859108ef4e5367f2b149ce4f3d28', 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 ('06FB0TNQM758PXMJH16PA3JHFW', 'task', '06FB0TNQM7AMEYEBTF8YGJ4BVG', 'Add a clide status one-shot snapshot command', 'Gap 6. Add a `status` command to the dispatcher (currently unknown -> exit 3) returning a single snapshot: active pane, focused file + selection, git summary, layout — the natural first call an agent makes to orient. Acceptance: `clide status` returns a structured snapshot with exit 0. Depends on the pane/editor registries (C1/C2) for its pane and file fields.
Scope addition (2026-06-03, from T-220): clide status must surface the focused file + selection from BOTH the editor (EditorRegistry.active, already populated) AND the read-only markdown/decisions reader''s current file (D-81 ReaderNav), since viewer files rightly do not live in EditorRegistry. So ''focused file'' = whichever of {active editor buffer, active reader doc} the user is currently looking at. Pane field comes from the T-219 view-pane snapshot; git summary from git.status; layout from the arrangement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 11:50:02', NULL, '9b5887b0dba408afb7721e957cc8c483', 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 ('06FB0TNQM7AMEYEBTF8YGJ4BVG', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Make the live workspace observable to the CLI (D-6 parity)', 'Gap 3 from self-analysis.md, verified in code. PaneRegistry (lib/src/panes/registry.dart) is written ONLY by the pane.spawn IPC path, so the built-in TabContribution panes — Claude (builtin/claude/src/extension.dart:42), Files (builtin/files/.../extension.dart:22), Editor (builtin/editor/.../extension.dart:52) — render in the kernel layout tree but never appear in `pane list` (read at pane_commands.dart:121). EditorRegistry is written by editor.open IPC (the Files tree uses it) but the active Claude pane / focused file / selection are not reflected. Result while the UI is in use: `clide pane list` -> {"panes":[]}, `clide editor active` -> {"active":null}. The ''agent sees what the user sees'' half of D-6 is missing.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-03 11:50:08', NULL, 'e7c51439a2c4e6f291284cf6add77602', 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 ('06FB0TNQM6GJRWZVHZF24TPPS4', 'task', NULL, 'Shift+Tab work-mode cycler on the focused Claude pane', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode <sessionId> <mode> (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82.
Refinement (2026-06-03): Shift+Tab is REJECTED as the trigger — Tab/Shift+Tab are real a11y focus-traversal intents (focus.next/focus.previous) since T-204, so hijacking Shift+Tab would break keyboard navigation. Instead: (a) Ctrl/Cmd+M cycles, intercepted at the focused Claude composer (so it targets that pane''s session, no global ''find focused session'' needed); (b) the status-line mode label becomes a FOCUSABLE button — cycles on click and on Enter/Space when focused (a11y-native, Tab reaches it); (c) a ''Claude: Cycle permission mode'' palette command. Safe trio default->acceptEdits->plan->default; bypassPermissions only via explicit confirmed path.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-03 09:37:54', '2026-06-03 11:50:23', NULL, 'ac8c4672be7aa07a7ade499da4acd9a3', 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 ('06FB0TNQM427BVZYN6MD7ZZX74', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'CLI verb to open a doc in a UI reader (drive-half of D-6 parity)', 'Dogfood finding (2026-06-03): Epic C gave the CLI the OBSERVE half of D-6 parity (clide status / pane list read live UI state). The DRIVE half is missing: there is no CLI/IPC verb that opens a doc in a UI reader. Opening a ticket/decision/markdown doc happens only inside the Flutter app, by publishing to the kernel MessageBus (e.g. messages.publish(''builtin.tickets'',''selection'',{''id'':id}); see tickets/src/extension.dart:29 which subscribes publisher ''builtin.tickets'' channel ''selection'', and ticket_detail_view.dart). lib/src/daemon/ has NO verb that reaches the MessageBus, so an agent cannot say ''look at this with me''. Proposed: add a bridge verb (e.g. ui.open / view.open <publisher> <id-or-path>) that publishes a ''selection'' message to the bus, so ''clide ui open tickets T-48'' opens T-48 in the ticket reader (and decisions/markdown analogously). Wiring: thread the kernel MessageBus into the dispatcher the same way panels/readerNav were threaded for T-219/T-221 (daemonClientFactory param or post-boot capture in main.dart). Honor D-6 (every UI action has a CLI verb). Complements Epic C (T-218): observe -> drive.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:17:32', '2026-06-03 13:30:05', NULL, 'f26d2dca0dc0d4d3c7b107347e2f22a5', 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 ('06FB0TNQM5BJCK5YG3R0NEEF70', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'CLI argv args dont reach typed command handlers (positional/flags vs named)', 'Major dogfood finding (2026-06-03), verified live: parameterized subsystem commands are NOT reachable from the clide CLI. The C client sends raw argv; parseArgv (lib/src/cli/argv_to_request.dart) turns ''clide editor open X'' into args={positional:[X]} (also flags:{}, passthrough:[]). But the typed handlers read NAMED top-level keys: editor.open reads args[''path''] (editor_commands.dart:64), files.read reads args[''path''], pane.close/editor.activate read args[''id''], etc. Nothing maps positional/flags -> those names, so every arg-taking verb returns ''X is required'' from the CLI. Confirmed: ''clide editor open pubspec.yaml'' and ''--path=pubspec.yaml'' both -> ''path is required''; ''clide files read pubspec.yaml'' -> ''files.read requires a path''. grep shows ONLY the new ui_command.dart reads args[''positional'']. Net: an external agent can OBSERVE (no-arg reads: status, git status, files root, pane list, editor active) but cannot DRIVE anything parameterized -- undercutting CLI-first (D-1) and the D-6 parity premise behind the whole T-208 initiative. Needs a decision on the mapping contract: most ergonomic is a per-command POSITIONAL/flag schema declared where the handler registers (extends the co-registered schema of D-74) so ''clide editor open <path>'' binds positional[0]->path; alternative is lifting --flags into top-level named args. Then either remap in argv_dispatch/parseArgv before dispatch, or have handlers read a normalized accessor. High priority: this is the gating bug for ''Give Claude hands''.
Resolved (2026-06-03): NO new mechanism or decision needed -- the contract already existed. D-74''s CommandSchema.normalize (lib/src/ipc/command_schema.dart) already folds the argv shape {positional,flags} into named args using a declared positional ordering, and the dispatcher already runs normalize+validate. The arg-taking commands simply never registered a schema (adoption is opt-in per D-74). Fix = adopt it: added positional schemas (non-required, so missing-arg errors stay as handlers produce them; only effect is positional->named mapping + numeric coercion of line/cols/rows) to editor.open/activate/read/save/close, files.read/ls, pane.close/focus/resize/write. Handlers unchanged. Tests: CLI-shape ({positional:[...]}) dispatch now binds (editor/pane/files command tests). DEFERRED (still named-arg only; in-process UI works, CLI-arg niche): editor.insert/replace-selection/set-selection/set-content (content/selection encoding + active-buffer fallback make positional ambiguous), pane.spawn (argv is a list, not scalar positional), git.* arg verbs (agents use plain git; D-83 external-agent scope). NOTE: takes effect on app RESTART, not hot reload -- the dispatcher is built once at boot.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-03 13:29:49', '2026-06-03 13:41:42', NULL, 'fc28f03686962cd26af10e60272af278', 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 ('06FB0TNQM5JR6QZDMKE167C4MR', 'task', NULL, 'add Catppuccin theme to bundled themes', 'User request: ship a Catppuccin theme alongside the four bundled themes (clide, midnight, paper, terminal — D-44).
**Acceptance:**
- New theme YAML at `lib/kernel/src/theme/themes/catppuccin.yaml` (or split for the four Catppuccin flavours: latte / frappe / macchiato / mocha — pick one or all four; recommend Mocha as the dark default and Latte for the light variant).
- Tokens populated for every key in `SurfaceTokens` (lib/kernel/src/theme/tokens.dart) — chrome, panels, tabs, list items, status colours, syntax tokens (D-45).
- WCAG-AA contrast gate (D-22) passes against the theme.
- Listed in `assets/licenses.yaml` with the upstream license (MIT) and attribution.
- Theme picker (`builtin.theme-picker`) shows it in the live-switch menu.
**Source palettes:** https://github.com/catppuccin/catppuccin#-palette — official hex values, don`t paraphrase.
**Why now:** test user request; also a good exercise for any future user-contributed theme since Catppuccin is the most common one to ask for.', 'done', 'low', NULL, NULL, NULL, '2026-05-06 08:30:15', '2026-06-03 14:56:07', NULL, 'e09db083ea034b33827a1bf708bd46bd', 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 ('06FB0TNQM492DTRZJK9CGQPGDC', 'story', NULL, 'Theme-switcher popout in the status bar', 'Add a theme switcher to the far-right corner of the bottom status bar (the empty area to the right of the ''application ok'' / ipc-status item — see the user''s screenshot). Today switching themes is only reachable via the theme.pick command / palette (ThemePickerExtension opens a modal picker_view); there''s no always-visible affordance.
Add a compact status-bar control that opens a POPOUT (popover anchored to the control, not the full-screen modal) for quick theme switching:
- A small right-aligned status item — a palette/swatch icon (or the current theme name). Contribute via StatusItemContribution at Slots.statusbar with priority > 100 so it sits in the right group, to the right of the ipc/toolchain ''application ok'' item (priority 100).
- Click (or focus + Enter/Space) opens a popover listing ThemeController.available, the current (ThemeController.currentName) marked.
- Selecting an entry applies it immediately via ThemeController.select(name) and closes the popout.
- Esc / click-away dismisses with no change. Keyboard navigable (arrow + Enter), matching the palette/quick-open overlay pattern.
- Natural home: ThemePickerExtension (lib/builtin/theme_picker). Reuse the theme list + select logic from picker_view rather than duplicating; the modal theme.pick command stays as-is for parity (D-6).
Acceptance:
1. A theme control sits in the far-right of the status bar.
2. Clicking/activating it opens a popover of all available themes with the current one marked.
3. Selecting a theme applies it live (ThemeController.select) and closes the popout.
4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable.
5. theme.pick (palette/command) still works unchanged.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 15:04:07', NULL, '0633fbd26056b04c5e542283be91a14e', 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 ('06FB0TNQM5GH6VP8TZA95NMYK0', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Cluster meta messages into a collapsible activity card', 'The Claude pane renders every transcript item as its own row, so a heavy agent turn becomes a wall of tool-call/result rows (Bash <cmd> / Bash · result {…} / ''completed with no output'') that buries the messages that matter (user + Claude prose). Wireframe: docs/design/wireframes/claude/meta-activity-card.png.
Fold runs of consecutive ''meta'' items into one live, collapsible activity card:
- The card shows the MOST RECENT meta line as a live ticker and updates in place as new ones stream in — a running ''what''s happening now''. A step count (''14 steps'') sits on the right.
- Collapsed by default; click the chevron to expand the full list of folded steps, click to re-collapse.
- ''Sticky'' items never fold — they render first-class. A sticky item SEALS the current card and a new cluster begins after it. A cluster = one unbroken run of foldable items between two sticky items.
Taxonomy over the sealed ConversationItem set (transcript_reader.dart): UserMessage, AssistantTextMessage, AssistantThinkingMessage, AssistantToolUse, ToolResultMessage.
- Always sticky: UserMessage, AssistantTextMessage. Permission/AskUserQuestion prompts already live in the interaction zone (D-78) and are inherently sticky / cluster-breaking — no change, but note they break the run too.
- Foldable: AssistantToolUse + its paired ToolResultMessage (pairing already exists via toolUseById, T-168).
Refinement decisions (2026-06-03, user):
1. WHAT FOLDS is a 3-level setting (conservative → aggressive); DEFAULT = level 1. Build the foldable predicate so all three are switchable:
- L1 (default): fold tool calls + results only; diffs (Edit/Write results) AND AssistantThinkingMessage stay first-class/sticky.
- L2: also fold thinking; diffs stay sticky.
- L3: fold everything except UserMessage + AssistantTextMessage (incl. diffs + thinking).
2. ERRORS: a failed/error ToolResultMessage SURFACES — treated as sticky, stays visible and breaks the cluster (see wireframe state B). Start here; the user noted some non-fatal errors are themselves clutter, so expect to tune which errors surface vs. fold.
3. DEFAULT/RUN STATE: always collapsed, showing the last line + step count, with the ticker updating live. NO setting. (Rejected auto-expanding the in-flight cluster: it scrolls the last useful sticky message out of view during long runs and only collapses when done — worst timing. Collapsed-with-live-ticker keeps the last sticky message anchored while preserving the motion/dynamism.)
Implementation home: a ''collapse adjacent foldable items'' grouping pass over ConversationController.items, consumed by ConversationView; the card is a new conversation_card variant. Keep it accessible (keyboard expand/collapse + semantics: announce step count and collapsed/expanded state).
Acceptance:
1. Consecutive foldable items collapse into one card; the collapsed card shows the latest line + a live-updating step count.
2. A sticky message (user / Claude prose / surfaced error / interaction-zone prompt) seals the card and starts a new cluster after it.
3. Expanding shows every folded step (tool call + result pairs) in order; collapsing returns to the one-line view.
4. The foldable level is a setting (default L1: diffs + thinking stay first-class); switching levels re-groups the transcript.
5. Failed tool results surface as their own (sticky) row and break the cluster.
6. Keyboard + screen-reader accessible (expand/collapse, step count announced).
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, '2b4264775c71be283f67c22aabc117f1', 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 ('06FB0TNQM46ANXPW02XGD3D3NG', 'task', NULL, 'Theme picker polish: collapse -hc into a checkbox, sort, widen, palette icon', 'User feedback on the T-234 status-bar theme popover (screenshot 2026-06-03): (1) the menu order looks random — the -hc variants are interleaved. (2) Hide all the high-contrast theme entries and replace them with a single ''[ ] High contrast'' checkbox at the top; toggling it applies the -hc sibling of the chosen base theme. (3) Widen the popover so long names (''Catppuccin Mocha'') don''t wrap; rows ellipsize. (4) The status-bar items (''application ok'' + theme switcher) float in the middle (boxed into the workspace column between the per-column bottom rails) instead of hugging the window''s right edge — align them right. (5) Replace the swatch dot on the trigger with a Phosphor icon (palette, 0xe6c8, already in the registry — paint-roller has no registered codepoint). Apply the -hc-as-checkbox + sort to the modal picker_view too for consistency. Helpers: base = name without -hc/-cb; show base themes only; resolve(base, hc) -> base+''-hc'' if it exists.
Done (2026-06-03): popover collapses -hc into a ''High contrast'' toggle, lists base themes sorted by display name, widened to 280 with ellipsis, palette icon on the trigger, lowercased bar label; status bar split so the global right group (tool status + theme switcher) hugs the window''s right edge past the context rail (643417e), keeping the 3-rail structure. Pure theme_families helpers unit-tested + popover widget tests. DEFERRED: applying the same -hc-toggle + sort to the modal picker_view (ctrl+k) for consistency — minor, follow-up.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 15:43:45', '2026-06-03 21:29:47', NULL, '881382733953e72c2de56ba5a3fdc9fe', 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 ('06FB0TNQM5V7GHH42RHV04DPTR', 'story', NULL, 'Vim keybinding preset', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work:
1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:<id>` bindings.
2. Add a small mode-tracking service that publishes `vim.<mode>` scope flags via `KeymapService.setScopeFlag`.
3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc.
**Acceptance:**
1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes.
2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions.
3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert).
Refinement (2026-06-01): scoped Vim-first for a Vim-power-user demo this weekend. ''Author a YAML file'' was wrong — T-117 shipped single-chord resolution + scope flags + when-clauses only. Decomposed into children: T-204 (fix dead default preset — live bug), T-205 (key-sequence + count resolution), T-206 (modal editor motion/edit intents), T-207 (Vim mode service + status indicator). T-65 itself becomes assets/keymaps/vim.yaml + regression tests once children land. T-64/T-66 deferred (single-chord, easy; T-205 hands JetBrains shift+shift later). Foundation is shared, not Vim-only.', 'done', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-04 12:00:09', NULL, '217a76ef73823789521206612b9835c8', 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 ('06FB0TNQM5BWXW61EVYGMZD47G', 'story', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Edit pencil in the reader action bar — open the current doc in the editor', 'Since clicking a .md now opens the READER (T-187), give the user a deliberate one-click way to open the SAME doc in the editor in edit mode. Add an edit pencil to the action bar of BOTH sidebar readers (markdown reader, decision reader). The pencil fires the editor path the readers moved away from for plain clicks: kernel.ipc.request(''editor.open'', {path}) on the reader''s current doc. Markdown reader: its current file path. Decision reader: the D-record''s backing file (decisions expose file_path = governance/decisions/<domain>.md via pql decisions read; resolve and open that — note it opens the whole domain file, since a decision is a section within it, unless we later add anchor support). Lives in the same reader chrome as back/forward (T-189) and pin (T-190). Custom chrome only (no Material). Acceptance: a pencil button in each reader opens the currently-shown doc in the editor; widget tests. Sequence with the other reader-chrome work (Wave B).', 'done', 'medium', NULL, NULL, NULL, '2026-05-31 11:58:19', '2026-06-04 12:00:09', NULL, '37b451f2b65b9bb9c0bca27383b84c69', 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 ('06FB0TNQM5ABRPK2SPW8VY7RP4', 'story', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Pin a doc in the markdown & decision readers (single-slot quick-return)', 'Add a one-click PIN to both sidebar readers. Semantics per the reporter: a SINGLE pin slot per reader — clicking pin remembers the CURRENT doc; setting a new pin REPLACES the previous one (no list to manage); the pinned doc is always one click away to return to, so it is easy to use inline as you work. It is a quick-return bookmark, NOT a freeze-incoming toggle. Add a pinned-doc ref to _MarkdownViewerState and _DecisionDetailViewState; a pin button (set/replace) plus a jump-to-pin affordance in the reader chrome; persist for at least the session. Custom chrome only (no Material). Acceptance: pinning a doc lets you one-click return to it later; pinning another replaces it; works on both readers; widget tests. Sequence after the reader-open bug fixes (T-187/T-188); pairs naturally with back/forward (T-189).', 'done', 'medium', NULL, NULL, NULL, '2026-05-31 11:38:42', '2026-06-04 12:00:09', NULL, '58323eaf0b443895404e88ff7f9977d1', 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 ('06FB0TNQM56FKPV4WSPWA0KMB4', 'story', '06FB0TNQM6S7FDAXQX32JNCSFG', 'Back/forward navigation on the markdown & decision readers', 'Add Back/Forward buttons to both sidebar readers so you can return to what you were reading. Neither reader keeps history today: _MarkdownViewerState (markdown_viewer.dart) holds only _path/_content; _DecisionDetailViewState (decision_detail_view.dart) holds only _decision. Add a history stack + index to each, pushing on each new open; Back/Forward walk the stack and load the entry WITHOUT re-publishing a ''selection'' (in-widget navigation). For the decision reader, back/forward must not trigger the extension tab re-contribution — coordinate with the decision-open bug fix (T-188), which should stop re-contributing per selection. Custom chrome only (no Material). Acceptance: Back/Forward controls on both readers walk reading history and disable at the ends; widget tests. Sequence after the reader-open bug fixes (T-187/T-188) since it shares those widgets.', 'done', 'medium', NULL, NULL, NULL, '2026-05-31 11:38:36', '2026-06-04 12:00:09', NULL, '8e500bb51805cf46d8abad4748f6e9a2', 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 ('06FB0TNQM4W624VD4JW1GHDHAR', 'task', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Claude sidebar sub-tabs: Activity / Team / Config', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:26:31', '2026-06-04 12:00:09', NULL, 'bee23004633073deaa93cb2d0154aa1e', 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 ('06FB0TNQM41S6RRTMTY9TYZA50', 'task', '06FB0TNQM614M7ES54V3SETAQM', 'T-115 follow-up: tap-driven widget test for sticky-startup toggle', 'T-115 follow-up: tap-driven widget test for sticky-startup toggle.
Adding a tap-driven (or even render-only) test that pumps WelcomeView with a non-empty recents list hangs the Flutter test runner until timeout.
Investigation 2026-05-22 (narrowed the cause):
- The hang is SYNCHRONOUS: `flutter test --timeout 45s` never fires; only an external `timeout` kills it. So the Dart isolate event loop is blocked, not awaiting.
- Reduced to a minimal repro: pumping two NESTED ClideTappable widgets under the shared test harness (test/helpers/widget_harness.dart) hangs. A SINGLE ClideTappable (as in _ActionRow) renders fine — that is why all existing WelcomeView tests pass (empty recents = no nested tappable). The recents path nests one: _RecentRow is a ClideTappable whose subtree contains _StickyToggle (a Semantics-wrapped ClideTappable).
- The shared harness uses `Overlay(canSizeOverlay: true)` over a zero-size `MediaQuery`, which triggers an intrinsic-sizing pass. Strongly suspect the nested ClideTappable (Focus + MouseRegion + GestureDetector + DecoratedBox stack) diverges under intrinsic dimension computation.
- Workarounds that did NOT help: setting tester.view.physicalSize; wrapping in SizedBox; wrapping in Center+SizedBox; a custom bounded MediaQuery harness (that custom harness hung even on a single tappable — likely its own confound, do not reuse it).
- The prior ClideTooltip/MouseRegion-timer hypothesis is WRONG: the recents widgets pass no tooltip to ClideTappable, so no ClideTooltip is built, and _StickyToggle uses Semantics(tooltip:) (metadata only, no widget/timer).
Next steps:
- Decide whether this is a real ClideTappable bug (nested tappables would also hang the live app welcome screen with recents) or strictly a canSizeOverlay-intrinsic-sizing test artifact. Check the running app: open welcome with >=1 recent project and confirm it does NOT hang. If the app is fine, the fix is harness-side (give WelcomeView tight constraints so no intrinsic pass), and the shared harness or a welcome-specific harness needs adjusting. If the app DOES hang, ClideTappable has a real nested-layout bug — fix it (likely in lib/widgets/src/clide_tappable.dart intrinsic/layout handling) and this becomes higher priority.
- Once unblocked, this also unblocks lib/builtin/welcome/src/welcome_view.dart coverage (74 uncovered lines, the dominant lib/builtin/ gap toward the T-89 95% target).
ProjectManager sticky-startup logic is already covered by test/kernel/src/project_test.dart (14 cases).
ROOT CAUSE FOUND + FIXED (2026-06-05). The strand is NOT nested ClideTappable / intrinsic sizing (that prior hypothesis is disproven: seeding an EMPTY recents list — no rows, no nested tappables — also hangs, while rendering real recent rows seeded correctly passes). Real cause: SettingsStore.set does real file I/O (writeAsString); awaiting settings.set + project.loadRecents INSIDE the testWidgets body runs that I/O in fake-async, which traps the completion callback so the await never returns (a +0 strand only SIGKILL clears). FIX: seed recents via tester.runAsync(() async {...}) (real event loop), then pump in a tight bounded tree (not the shared harness, whose unbounded width is a separate WelcomeView layout hazard). test/builtin/welcome/widget_test.dart now has working recents render + sticky-toggle + open-recent tests; the skip is removed. Harness lesson recorded.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 09:06:59', '2026-06-05 13:08:05', NULL, 'c94dba8a56ab16c17e26439dece58f47', 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 ('06FB0TNQM66TK8TQ1X5T9GK6V0', 'task', '06FB0TNQM4XZA5399D2EHYCVFC', 'Decide diff view placement', 'Diff view currently contributes to workspace. Spec doesn''t place it. Likely belongs in context panel (viewer) or as an editor overlay. Depends on Q-27.
DECIDED 2026-06-05 (D-84): diff view = editor-mode surface, inline above Claude (NOT a workspace tab, NOT a context-panel viewer), spawned from the git sidebar. Rationale: a diff is a review+intervention surface needing room for hunk stage/discard and conflict-resolution widgets — too cramped for the ~420px context panel; the middle column above Claude gives space while keeping Claude''s prompt bar fixed (D-47/D-49). This decision ticket is satisfied; the actual re-placement (generalize the editor-mode slot to host a diff, wire git-sidebar spawn, build resolution widgets) is a follow-up implementation story.', 'done', 'low', NULL, NULL, 'D-48', '2026-04-22 21:03:40', '2026-06-05 15:17:09', NULL, 'eb79594591de24156f21a5a3deca97f9', 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 ('06FB0TNQM5WT3BHB8JZY77G5AW', 'story', '06FB0TNQM4XZA5399D2EHYCVFC', 'Move diff view to the editor-mode surface (D-84): spawn from git sidebar, above Claude', 'Implements D-84. Today the diff view is a workspace TAB (lib/builtin/diff/src/extension.dart, slot: Slots.workspace) — remove that. Instead: (1) generalize the editor-mode surface (currently hosts the single inline editor in _WorkspaceSlot, app.dart, gated by arrangement.editorOpen/editorRatio) so it can host EITHER the editor or a diff (mutually exclusive in that slot, like the viewer<->editor swap in D-49); (2) selecting a changed file in the git sidebar opens its diff in that above-Claude surface; (3) the diff keeps Claude''s prompt bar fixed (D-47) and gets a draggable divider like the editor. Acceptance: diff no longer appears as a workspace tab; clicking a file in the git panel opens its diff above Claude; closing it (Ctrl/Cmd+W) returns to Claude full-height; coexists with the editor by swap, not both at once. Hunk stage/discard + conflict-resolution widgets can be a further follow-up. Tests: arrangement state for the diff surface; git-sidebar -> diff open wiring; widget render.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-05 15:14:25', '2026-06-05 15:17:09', NULL, 'f97772f7c06252c76de0d158a7d6c3d8', 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 ('06FB0TNQM4KPN4VK4EZP013NNG', 'bug', NULL, 'Claude pane shows no UI or status-bar progress while compacting', 'When a Claude Code session hits context compaction, the Claude pane gives no signal that anything is happening: the conversation surface shows no in-pane indicator and the status bar shows no progress. From the user''s side the pane just appears to stall until compaction finishes and the next response streams in.
Expected: compaction is a visible, first-class state.
- In-pane: a clear "Compacting context…" affordance on the conversation surface (e.g. a transient status card / banner in the live-status line from T-168) so it''s obvious the session is busy, not wedged.
- Status bar: a progress indicator in the focus-driven Claude slot (T-150) reflecting the active compaction, consistent with the long-running-operation pattern in T-59.
Implementation notes:
- Source the state from the stream-json event stream (T-164/T-165), not transcript tailing — identify the compaction signal (system/compact_boundary or equivalent event) and thread it through the conversation controller into both the in-pane live-status line and the status-bar slot.
- Clear the indicator when compaction completes and normal streaming resumes.
Acceptance: triggering /compact (or an auto-compaction) shows an in-pane "compacting" indicator and a status-bar progress affordance for the duration, both of which clear when it finishes; covered by a unit test against a canned event fixture containing the compaction event.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-05 15:21:10', '2026-06-05 15:24:35', NULL, '8f0b6c74540f9e8d68c2ce076d8f13fb', 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 ('06FB0TNQM5YYY4FVPX9KRVHCMC', 'story', NULL, 'toast notification system', 'Non-modal toast notifications for operation feedback (git push succeeded, extension activated, update available, errors). Slide in from bottom-right or top-right, auto-dismiss after timeout, manually dismissable. Queue multiple toasts. Severity levels map to status tokens (success/warning/error/info).
REFINED 2026-06-05. Build (bottom-right, system + proof emitter):
- kernel ToastService (ChangeNotifier): show(message, {severity, duration}) -> id, dismiss(id), queue of active toasts, per-toast auto-dismiss Timer (default ~4s; errors longer/sticky), cancel on manual dismiss. ToastSeverity {success, warning, error, info}.
- ClideToast widget (custom, NO Material per D-7): severity accent from status tokens (statusSuccess/Warning/Error/info), message text, dismiss affordance (x / Esc-less, click); slide+fade in from bottom-right, auto slide-out.
- ToastOverlay: mounted in the app-root Stack (app.dart) alongside ClidePalette/QuickOpenOverlay, anchored bottom-right; stacks multiple toasts vertically; animates enter/exit.
- a11y: Semantics liveRegion for the message + labelled dismiss; keyboard-dismissable.
- Proof emitter: git push success/failure raises a toast (wire in the git command path). Broad wiring (extension activated, update available, generic errors) = follow-up tickets.
- Tests: ToastService queue + auto-dismiss (fakeAsync timers) + severity mapping; ClideToast render per severity; a11y contract; overlay mount.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:31:48', '2026-06-05 21:12:23', NULL, 'e0477e4edfd5043e7f47082375985ed6', 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 ('06FB0TNQM66DNEK5FS9C0T4J5R', 'task', '06FB0TNQM5YYY4FVPX9KRVHCMC', 'clide ui toast — raise a toast from the CLI (D-6 drive-half for T-50)', 'Implements the agent/CLI drive-half of T-50 (parity, like ui.open/T-231): clide ui toast "message" [--severity success|warning|error|info] [--duration MS]. Registered as ui.toast in lib/src/daemon/ui_command.dart; publishes {message,severity,durationMs?} on the kernel MessageBus ''toast'' channel (literal kept Flutter-free), which the ToastService consumes. Lets a hosted Claude session (or any script) surface ''done/failed'' on the user''s screen. Validates message-required, severity, integer duration; toolError when no live GUI. Tested in test/daemon/ui_command_test.dart. NOTE: a running GUI must be rebuilt to pick this up.', 'done', 'medium', NULL, NULL, NULL, '2026-06-05 21:20:00', '2026-06-05 21:24:52', NULL, 'b0058695aa24a1418720d2d33ef1c51e', 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 ('06FB0TNQM6DH7C6GQN9WNKQNXR', 'bug', NULL, 'pane list omits the loaded subject — CLI can''t tell which ticket/decision/file a detail pane shows', '`clide pane list` reports every GUI tab (T-219, D-83) but only its generic chrome: `id`, `title`, `active`, `visible`. For the context-slot detail panes the title is a static word — `tickets.detail` → "Ticket", `decisions.detail` → "Decision", `editor.active` → "Editor" — so the CLI cannot tell *which* ticket / decision / file the pane currently shows. The loaded subject lives in the view''s reader-nav state and never reaches the wire.
Concrete failure: with a ticket open in the right-hand context pane, there is no `clide` verb that answers "what ticket am I looking at". `pane list` shows `{"id":"tickets.detail","title":"Ticket","active":true}` and stops there. This breaks D-6 parity — the UI surfaces the open ticket; the CLI can''t observe it.
Fix: give `ViewPane` (lib/src/panes/view_pane.dart) an optional `subject` (and/or `subtitle`) field and have `snapshotViewPanes` (lib/kernel/src/panels/view_pane_snapshot.dart) populate it from the active reader-nav selection for detail panes — `tickets.detail` → `T-NNN`, `decisions.detail` → `D-NNN`, `editor.active` → the file path. Serialise it in `ViewPane.toJson` so `pane list` carries it. Keep ViewPane Flutter-free (it must stay usable under `dart test`); read the selection in the kernel-side snapshot, not in the value object.
Acceptance: with a ticket open in the context pane, `clide pane list` returns that pane with its loaded id (e.g. `"subject":"T-244"`); same for an open decision and an open editor file; panes with no subject omit the field; covered by a unit test over a snapshot with a populated reader-nav selection.', 'backlog', 'high', NULL, NULL, 'D-6', '2026-06-05 21:28:44', '2026-06-05 21:29:50', NULL, '31e0b8f73eae2c4c23d8bbe8265c4c53', 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 ('06FB0TNQM6QCYT7JBF352YVJ48', 'bug', NULL, 'clide CLI ignores CLIDE_SOCK and auto-discovers; stale sockets orphaned in runtime dir', 'The `clide` CLI does not honor the `CLIDE_SOCK` env var — it auto-discovers a running instance instead. Proof: with `CLIDE_SOCK=/run/user/1000/clide/DOES_NOT_EXIST.sock`, `clide version` still returns `{"version":"2.1.0"}` from the live app. Separately, the runtime socket dir accumulates orphaned socket files: `/run/user/1000/clide/` held two `.sock` entries while only one GUI process (the `build/linux/x64/debug/bundle/clide` bundle) was running, so a previous run''s socket was never cleaned up.
Why it matters: today (single instance) it''s harmless, but it''s a latent split-brain + observability hole. If two clide instances are ever live on the same machine, the CLI attaches to whichever discovery resolves first, with (a) no way to target a specific instance and (b) no way to find out which one you''re talking to. Combined with the stale-socket litter, `clide` could silently drive the wrong window. This is a D-6 surface gap — the CLI must be able to address the same instance the user is in.
Fix (scope to confirm):
- Honor `CLIDE_SOCK` when set (explicit target beats discovery); error clearly if that socket is dead rather than silently falling back.
- Clean up orphaned/stale socket files on app startup (and on clean shutdown) — detect a dead listener and unlink before binding a new hash.
- Add a way to enumerate/identify live instances (e.g. a `clide instances` verb, or include the instance id/socket path + pid in `clide version`) so a human or agent can pick the right one.
Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a different instance; a valid `CLIDE_SOCK` pins the CLI to that instance; startup leaves exactly one live socket for one running app (no orphan accumulation); there is a CLI affordance to list/identify running instances.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-05 21:28:48', '2026-06-05 21:30:04', NULL, '7fd8f211a761d1974b5b51fd7026e594', 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 ('06FB0TNQM5F4D2Q363QF7V40K8', 'bug', NULL, 'Status-bar right group still not hugging the center block''s right edge', 'Unresolved after T-237 (commits 643417e then reverted by dc84f4c). GOAL: in the single bottom status bar, left items (git branch ''main'', skills count) sit flush-LEFT and the global right group (ipc ''application ok'' + theme switcher) sits flush-RIGHT of the CENTER (workspace) block — NOT the window edge, and NOT two separate left-aligned columns. Keep the 3-rail bottom bar (sidebar rail | workspace status | context tab-rail). CURRENT STATE (dc84f4c): StatusbarHost is a single Container(Row[ ...left, Spacer(), ...right ]) with NO alignment, sitting in Expanded(StatusbarHost()) inside the bottom-bar Row (app.dart ~261). In theory the Spacer should right-align the right group within the workspace Expanded; in practice (verified live by the user, even after a hot RESTART) the right group does NOT hug the center block''s right edge. ATTEMPTS THAT FAILED: (a) removing ''alignment: Alignment.center'' from the StatusbarHost Container; (b) splitting into two hosts and trailing the right group past the context rail (overshot to the window edge — wrong). LEAD / NEXT STEP: instrument the actual constraints rather than guess. Prime suspect: the OUTER Column at app.dart ~214 has NO crossAxisAlignment (defaults to center), so the bottom-bar Container may receive LOOSE width and size to its content, collapsing the Spacer (right group ends up adjacent to the left group, both effectively left-aligned). Try CrossAxisAlignment.stretch on that Column (or give the status Container an explicit full width), and confirm Expanded(StatusbarHost) actually receives a bounded full-workspace width. A widget/integration test asserting the right group''s x-offset == workspace-region right edge would lock it. Files: lib/app.dart (StatusbarHost + the bottom-bar Row + the outer Column).
FIXED (2026-06-05): root cause confirmed by probe at 3440px width — the bottom bar used Row[ ...left (incl. a Flexible flex:1 loose item, the Claude status marquee), Spacer(), ...right ]. The Spacer (Expanded, flex:1) and the left flex:1 item SPLIT the free space 50/50, so the Spacer only pushed the right group by HALF the free space. That drift is proportional to width: tiny at 1200px (why probes/normal screens looked fine), ~1500px at 3440px (the right group floated to mid-bar) — hence ''only on ultrawide''. Probe: OLD R.right=1934 at a 3440 edge; NEW R.right=3440. FIX (app.dart StatusbarHost): explicit two-column layout — left group wrapped in Expanded(Row[...]) so it absorbs ALL free space (flex item flexes within it), right group trails at intrinsic width → hugs the workspace block''s right edge by construction, width-independent. Regression test test/app_statusbar_test.dart asserts the right group at width-8 for BOTH 600px and 3440px. Full fast suite green.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 21:41:58', '2026-06-06 06:44:40', NULL, '8534c325d61e138f74d6d03ca85496f4', 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 ('06FB0TNQM5F2GC0ABFFVAH34EC', 'task', NULL, 'Apply the -hc-as-checkbox + sort to the ctrl+k theme modal (T-237 follow-up)', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.
DONE (2026-06-06). Re-scoped per user: instead of just applying the -hc-checkbox+sort polish to the theme modal, the modal was PROMOTED to a general Settings modal (picker_view.dart -> settings_view.dart, ThemePickerView -> SettingsView). ctrl+k / theme.pick now opens Settings, whose only section today is Appearance: base themes (sorted) + a High contrast toggle, reusing the theme_families helpers (shared with the status-bar popover, T-237). Command id kept as theme.pick (welcome link + tests reference it); retitled ''Settings...''. Extensible: more sections later (or split to a builtin/settings extension when it grows). Tests in test/builtin/theme_picker/widget_test.dart (base-only list, hc toggle applies sibling). NOTE: status-bar popover (T-237) was already done — that is what the user saw ''done in the ui''.', 'done', 'low', NULL, NULL, 'D-6', '2026-06-03 21:11:40', '2026-06-06 07:24:00', NULL, 'e6a5dd2bf5172a8b88ccac69014815be', 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 ('06FB0TNQM7ZQNDVPAPFSD9KYS4', 'task', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'Command palette: pinned commands + cross-session recency persistence (T-23 follow-up)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '966e6962f8f2c381c3d65a18edfa01f6', 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 ('06FB0TNQM4BVE5EYM92CWGSXA4', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated.
Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback.
- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)).
- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause.
- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`.
- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status — so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead.
So the status bar / badge value is not updated on change — matching the reported hypothesis.
Fix direction:
- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it.
- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) — if the response reports failure, surface it rather than silently assuming success.
Acceptance:
- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line.
- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess.
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'done', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:49:08', NULL, 'cb4f4331f0aedf5a61dd350ab1dfdde7', 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 ('06FB0TNQM7JXD3FKW91934D0W0', 'story', NULL, 'Image viewer conversation card + plumbing', 'Add an image-viewer card to the Claude conversation log, with the CLI verb that drives it (D-6 parity).
Scope:
- Conversation card: render an image inline in the Claude conversation widget — clide-owned rendering (no opinionated package), display-only per D-78 (no inline interactive controls; any controls belong in the interaction zone). Respect theme tokens / ui-design.
- Plumbing: a `clide` verb to show an image in the log (e.g. `clide image show <path>` or via the pane subsystem), accepting a workspace path; define accepted formats (PNG/JPEG/...), path resolution (workspace-relative), and sizing/scaling behavior.
- Parity (D-6): the verb is the CLI counterpart of the card; ensure it registers in the dispatcher so it shows up in the discovery verb from T-248.
Dependency: pairs with T-248 — without the /clide skill + discovery verb, Claude won''t know this card/verb exists even once shipped. Build the plumbing here; T-248 makes it discoverable.
Refs: D-6 (parity), D-78 (interaction zone, display-only conversation widgets). Related: T-248.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:21:40', NULL, '2c320b610b77976f25199581c3d04f04', 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 ('06FB0TNQM70TW38E78FFXDHYAC', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable.
Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide <subsystem>` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there.
Scope:
- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts.
- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct.
This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel.
Refs: D-6 (CLI/event-surface parity). Related: T-249.
RE-SCOPE (2026-06-06, from T-208 completeness review): partly overtaken by reality — do NOT build from scratch.
- The `clide capabilities` verb already EXISTS in code: registered in lib/src/daemon/dispatcher.dart:12 (built-in) and as an umbrella command in lib/src/cli/argv_to_request.dart:28. Its handler (_capabilities, dispatcher.dart:86) enumerates the live registry with arg schemas — so the non-rotting design is already implemented server-side.
- A `/clide` skill is ALSO already present this session (other ongoing work), and it instructs agents to "Start with `clide capabilities` to enumerate the live tool surface."
- BUT: against the live instance, `clide capabilities` returns a usage stub at exit 0 (`usage: clide capabilities <verb> [args...]`) instead of the command table — i.e. the discoverability entry point the skill advertises is currently DEAD. Likely a stale running build or a C-client umbrella mismatch (native/clide-cli/clide.c forwards argv but the live server treats `capabilities` as a subsystem needing a verb).
New scope for this ticket:
1. Verify/fix the live `clide capabilities` path end-to-end (rebuild/restart vs real wiring gap in the C client / server umbrella handling). Acceptance: `clide capabilities` returns the JSON command table against a running instance.
2. Reconcile with the already-shipped `/clide` skill rather than authoring anew — confirm it points at the working verb and covers the conventions.
Net: this is now a verify-and-reconcile task, not a build-from-scratch one. Related: T-249, T-208.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:31:25', NULL, '2d81d44df3ca33ac1c5ada98d842519a', 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 ('06FB0TNQM5NEXC0P46NJHACNM0', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'Open a file in the diff panel for review via ui.open', 'Follow-up from T-231 (2026-06-03). T-231 added ''clide ui open <reader> <id|path>'' for the ReaderNav-backed readers (tickets/decisions/markdown), which open by subscribing to a MessageBus ''selection''. The DIFF panel is not covered: DiffController (lib/builtin/diff/src/diff_controller.dart) loads the git working-tree diff via the git.diff IPC verb and refreshes on git.changed events; it has no ''selection''/''focus'' bus channel, so ui.open has no entry point to target a file. Note: because the diff panel shows the working tree, an agent''s uncommitted edits already APPEAR there on the next git.changed refresh -- what''s missing is (a) bringing the diff tab to the front and (b) scrolling/filtering to a specific file. Proposed: give the diff view a bus subscription (publisher ''builtin.diff'', channel ''selection''/''focus'', {path}) that reveals its tab and focuses/filters that file''s diff (DiffController.load already accepts a paths filter), then add ''diff'' as a 4th ui.open target. Related gap: pane.focus is advisory-only (pane_commands.dart) so ''bring tab to front'' isn''t truly wired -- the reveal likely needs the same mechanism the readers use to reveal their tab on selection. Depends in spirit on T-232 (CLI args) only if a non-bus path is chosen; the bus path works regardless.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:29:59', '2026-06-06 09:20:02', NULL, 'f0131be14b518c6169966a8719b5700b', 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 ('06FB0TNQM78Q3VYWSE830CTVB4', 'task', '06FB0TNQM4QYYKHPQ03H3QD61W', 'In-app ''Install clide command in PATH'' affordance', 'VS Code-style: the GUI offers to install the `clide` shell command on PATH, for users who run the .app without `make install`. A command + command-palette entry that copies/symlinks the bundled C client to a PATH dir and reports success/failure. Acceptance: invoking it makes `clide` resolve on PATH from a fresh shell.
Refinement (2026-06-03, from live dogfooding): scope should include PROACTIVE detection on launch, not just a palette command. When the clide IDE starts in a repo, check whether ''clide'' resolves on PATH AND points to the C client (not a stale symlink to the GUI bundle runner) -- we hit exactly this: ~/.local/bin/clide was a May-6 symlink to ~/.local/lib/clide/clide (the Flutter GUI), so a bare ''clide pane list'' launched a second app instead of querying. If missing or stale, prompt/offer to install (copy the bundled C client to a PATH dir, VS Code ''Install code command'' style) and report success. This is what lets a fresh agent actually reach the CLI (D-83 names the hosted session primary, but an external agent benefits too). Detecting ''stale GUI symlink'' specifically: the target should be an ELF/Mach-O executable, not a symlink into the bundle. Alternative path the user raised: instead of/alongside this, make the /ide MCP surface reachable (T-225) -- but CLI is primary per D-68.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-06 10:02:03', NULL, '58895405f0e740d4b8db4a37e8ca3f87', 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 ('06FB0TNQM4QYYKHPQ03H3QD61W', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Ship the clide CLI on PATH', 'Gap 1 (BLOCKER) from self-analysis.md. The C client native/clide-cli/clide.c (T-126, done) is the real CLI, but there is no supported way for `clide` to land on an agent''s PATH:
- `make clide-cli` (Makefile:284) is a non-default target; `build` (Makefile:169) does not depend on it, so the binary is never produced by a normal build (native/macos-arm64/clide had never been built).
- `make install` (Makefile:195) copies the GUI bundle and `ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide` (Makefile:200) symlinks the FLUTTER GUI runner — not the C client — on BOTH macOS and Linux (on macOS that path doesn''t even resolve inside clide.app).
Net: `which clide` -> not found, `clide ping` -> exit 127. The CLI-first contract (D-1) has no delivery mechanism. Related to the broader installer epic T-46, but that is the GUI/runtime installer; this epic is specifically C-client-on-PATH delivery.', 'done', 'high', NULL, NULL, 'D-1', '2026-06-02 18:13:52', '2026-06-06 10:02:06', NULL, '5a5a8c818ff51c55ff8fd57914cce71b', 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 ('06FB0TNQM4K2AAPK52Y8BEJ3YR', 'bug', NULL, 'Esc closes the file/pane instead of returning to Vim normal mode', NULL, 'done', 'high', NULL, NULL, NULL, '2026-06-06 14:01:03', '2026-06-06 18:58:25', NULL, '7c4ca77bf4e0ee1116bfd5b4964ef21a', 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 ('06FB0TNQM5KYAV8TMH3Y3FPRSR', 'task', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).
DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'done', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 19:06:30', NULL, 'a31b3cddfbdc254bd7b908b4ebfce28c', 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 ('06FB0TNQM72ACQSDBJTKRBC58W', 'task', '06FB0TNQM620F5ENRYNT9S0JDW', 'Add cursor-based pull events: clide events --since <cursor>', 'Return all events since a cursor plus a new cursor, alongside the existing stream (T-129). Acceptance: `clide events --since <c>` returns events + next-cursor with exit 0; repeated calls neither drop nor duplicate. Coordinate the cursor/persistence design with open questions Q-2 (event back-pressure) and Q-3 (event persistence + audit) — this dovetails with both.
Refinement (2026-06-06): blockers resolved. Q-2 + Q-3 closed by D-85 (event bus delivery). Design is now fixed: serve --since from a bounded in-memory ring keyed by a monotonic cursor; return events-after-cursor + next-cursor, and a gap marker (per-subscriber dropped-count) when the requested cursor has aged out so callers detect loss rather than silently miss events. Back-pressure is drop-oldest (producer never blocks, subscribers never killed). No on-disk persistence in v1. Ready to implement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-06 19:24:44', NULL, '8479cf97831fa506407dcffb548c1b13', 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 ('06FB0TNQM620F5ENRYNT9S0JDW', 'epic', '06FB0TNQM7XMQ44AVCAARQZX14', 'Agent-friendly event observation (pull-based)', 'Gap 4 from self-analysis.md. `clide tail --events` (T-129) is a never-returning stream — awkward for a request/response agent loop; the agent must background it and poll a file or it misses events between polls. Provide a pull-based form that fits an agent loop natively.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 19:24:44', NULL, 'b5bc5abd92e28abe4f7c05369038f054', 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 ('06FB0TNQM5DYC7CRP8TMHADW8W', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'MCP transport reachable by an external agent', 'Gap 6 from self-analysis.md. The MCP SSE server (T-130, D-68) is live but mcp__ide__* tools are not exposed to an external agent out of the box, and getDiagnostics/executeCode were noted as stubs. The CLI path is the priority (Epics A-C); MCP follows. Decide + wire the minimal reachable MCP surface, or explicitly defer with a note. Relates to open question Q-32 (minimum MCP tool surface).
Refinement (2026-06-06): tool-surface question resolved. Q-32 closed by D-86 — expose the full mcp__clide__* namespace, but GENERATE tools/list from the co-registered command registry (D-74) that already feeds the CLI + palette, so there is no hand-maintained second surface; add a per-command MCP opt-out for poor-fit verbs (long-lived streams, UI-side-effecting). Transport stays SSE-only per D-73 (Q-33 re-confirmed, not reopened). Remaining stubs to make real: getDiagnostics + executeCode. Scope is now: registry->MCP tool-definition adapter (arg-schema -> JSON-Schema), served over the existing SSE transport.', 'done', 'low', NULL, NULL, 'D-68', '2026-06-02 18:13:52', '2026-06-06 19:37:47', NULL, '7ac30a4d6cdb64bdf8d8b609c8388751', 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 ('06FB0TNQM7XMQ44AVCAARQZX14', 'initiative', NULL, 'Give Claude hands', 'Make a Claude agent able to actually WORK inside clide: reach the CLI, know clide is hosting it, and observe the same workspace the user sees. Source: self-analysis.md (2026-06-02), a live dogfood probe by a Claude (Opus 4.8) running against a live instance (socket + MCP SSE up, version 2.1.0).
Finding: the IPC/CLI foundation (D-1, D-6, D-56; built under T-99 incl. the C client T-126 and the `clide tail --events` stream T-129) works end-to-end at the socket level. But a fresh agent cannot REACH or OBSERVE it. Three blockers:
1. No `clide` binary on PATH, and no install path that delivers one (Epic A — blocker).
2. No bootstrap: nothing tells a fresh agent clide is running or where its socket is (Epic B).
3. The live UI panes / active file are not reflected in the CLI registries, so the D-6 parity premise (agent sees what the user sees) breaks (Epic C).
Plus: a governance decision on WHICH agent model is the dogfood target (hosted stream-json session vs external CLI driver vs both) and an MCP follow-up. The pleasant surprise per the probe: the hard part (a live, correct, single-process IPC contract with proper 0/1/3 exit codes) is done; what''s missing is the last mile that puts the tool in the agent''s hands. NOTE: the report says ''daemon'' but D-56 dissolved it — this is the in-process IPC server; the socket contract is unchanged.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 19:53:07', NULL, '4f9ba8adbe68c68f76d76acda314dd3c', 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 ('06FB0TNQM6S7KHYPE9W7S1JHZ8', 'task', '06FB0TNQM7XMQ44AVCAARQZX14', 'Install affordance: distinguish a dev-tree clide from a production-installed build', 'Flagged during T-212 (2026-06-06). The Install-clide-command affordance copies whatever CliInstaller resolves as the bundled C client into ~/.local/bin, and ''make run'' points CLIDE_CLI_BIN at the dev-tree client (native/<plat>/clide). On a normal user machine that is correct: the bundled client beside the GUI runner IS the production build. But on a clide DEV system the clide on PATH should point at the properly-installed production build, not a dev-tree binary or stale build artefact. CliInstaller.inspect only classifies missing / staleGui (points into the Flutter bundle) / installed, and treats any non-GUI binary as installed without distinguishing dev-tree vs a real install. Good enough for now per the user; the affordance works for end users and fixes the stale-GUI-symlink footgun. Decide desired dev-machine behaviour (e.g. detect dev tree via CLIDE_PROJECT / repo-relative path and warn instead of silently installing, or prefer an already-correct production build) and refine inspect() accordingly.', 'done', 'low', NULL, NULL, NULL, '2026-06-06 10:07:35', '2026-06-06 19:53:07', NULL, 'd2fec2ab90638b74a87ec39146730cdd', 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 ('06FB0TNQM6WZ7DKVDFAGQ4QDMG', 'story', NULL, 'Terminal as a first-class editor-pane surface (swap vs split)', 'Spun off from the T-54 dock design (D-87), which scoped the bottom dock to read-only output (logs + problems) and explicitly kept the terminal OUT of it. Terminals are core functionality, not an afterthought: the terminal should live in the editor pane (above Claude, D-49 editor-mode) as a first-class peer of editor and diff — not in a read-only log strip.
First-class means: reachable by a dedicated keybinding (e.g. Ctrl+`), multiple terminals, and tmux-backed persistence (D-41/D-75) so swapping away never stops a running terminal — you swap back and the build is still streaming. Today the terminal is a generic workspace tab; this moves it into the editor-mode family.
OPEN QUESTION (decide here): clide''s editor-mode is swap (one surface above Claude at a time), so you cannot currently watch a terminal AND edit a file side-by-side. Options: (a) swap-only, leaning on tmux persistence + a fast toggle; or (b) allow the editor pane to split so a terminal can sit beside editor/diff. This is a facet of Q-27 (two-editor split) — resolve them together. Relates to D-47 (Claude-is-home), D-48 (chrome budget), D-49 (editor-mode), D-84 (diff placement, the existing editor-mode peer).', 'backlog', 'medium', NULL, NULL, 'D-49', '2026-06-06 20:55:49', '2026-06-06 20:55:49', NULL, '6108bdb6bc0ea059d93f12021fe617cf', 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 ('06FB0TNQM7RQMN1GKEZ5JQKJT0', 'epic', NULL, 'Interaction model — context panel, readers, live-sync, focus mode', 'Home for the D-47D-57 interaction-model surface that isn''t a numbered build tier: the reactive context panel (D-50), editor-mode viewer↔editor swap (D-49), panel collapse/spine (D-51), focus mode (D-52), layout state persistence (D-53), and the keyboard map (D-54). Created 2026-06-06 to re-home T-36, which was mis-parented under T-7 (Tier 5 canvas/graph). These behaviors span tiers and belong to the interaction model rather than a single feature tier.', 'backlog', 'medium', NULL, NULL, 'D-47', '2026-06-06 21:12:44', '2026-06-06 21:12:44', NULL, '22b26d155a9fdffa44909ed4811c42f4', 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 ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds).
Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only — no edit affordances in the mirror.
HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown <path>` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'backlog', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-06 21:14:22', NULL, '6609c1a39c7dd974a0055ab2ed80e3cd', 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 ('06FB0TNQM6DSPH569T4458B8JC', 'story', NULL, 'Annotation tools on the image overlay — boxes, arrows, labels for visual dialog with Claude', 'Add an annotation layer to the full-screen image overlay (T-252) so the user can draw boxes, arrows, and text labels directly on a shown image and have a VISUAL DIALOG with Claude about it — Claude shows an image, the user marks it up ("this button is misaligned", arrow pointing at the bug, box around the region), and the markup is communicated back to Claude.
The crux is the round-trip: annotations are not just decoration, they are a message to Claude. The overlay must turn what the user draws into something Claude can consume.
Scope:
- Annotation layer (lib/widgets/): a CustomPaint overlay composited on top of the T-252 zoom/pan viewer. Tools: rectangle/box, arrow, freehand pen (optional), and text label. Own-the-rendering-stack — draw with CustomPaint, no opinionated annotation package. Annotation geometry is stored in NORMALIZED IMAGE-SPACE coordinates so it survives zoom/pan and maps cleanly back to the original pixels. Select / move / delete an annotation; undo/redo; per-annotation color from theme tokens (ui-design). Min one accent + a couple of palette colors, not a full color picker.
- Interaction surface (D-78): the overlay is a modal interaction surface, so the annotation toolbar lives in the overlay chrome (not inline in the display-only conversation card). Tool palette, color, undo/redo, clear, and a confirm/send affordance. Keyboard + AT reachable (ClideTappable / Semantics), Esc still dismisses.
- Round-trip to Claude — TWO outputs on confirm: (1) a FLATTENED PNG (image + annotations rendered together) written to a workspace path so Claude can view it via the existing image card / clide image show (T-249); (2) a STRUCTURED text summary of the annotations (tool kind, normalized coords, label text, color) so Claude gets machine-readable intent, not just pixels. Decide whether send drops both into the composer / interaction zone as the next user turn. This is the visual-dialog loop and the main design question of the ticket.
- Parity (D-6): a CLI counterpart so annotations are not UI-only — e.g. a verb to open an image with a starting annotation set, and/or to emit the flattened+structured result. Register it so it surfaces in clide capabilities (T-248).
Open questions to resolve in design:
- Exact send/transport: composer injection vs a dedicated clide verb that posts the annotated turn.
- Structured-annotation schema (shared with canvas/graph later?).
- Whether annotations persist with the image or are ephemeral per-overlay-session.
Refs: D-78 (interaction zone / display-only conversation widgets), D-6 (CLI/UI parity). Builds on T-252 (lightbox overlay) and T-249 (image card + clide image show). Related: T-248 (discovery), T-259 (interaction-model epic).', 'backlog', 'medium', NULL, NULL, 'D-78', '2026-06-06 21:46:42', '2026-06-06 21:46:42', NULL, '5322506b7dc2d05515fd978118b8d5de', 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 ('06FB0TNQM7GJW4V3CXMMXXV988', 'task', '06FB0TNQM7GCVGCH9RMGYHENCC', 'Output dock: drag-resize the dock height', 'Follow-up from T-54 (D-87). The bottom output dock ships with a persisted default height but no drag-to-resize handle. DragResizeHandle supports Axis.vertical but its drag-sign logic (lib/kernel/src/panels/drag_resize.dart _onMove + bumpedSlotSize) only negates for contextPanel; a bottom dock with a top handle needs the same negation (drag up = grow). Add Slots.dock to those sign cases, render a vertical DragResizeHandle above the dock in RootLayout (lib/app.dart, between the workspace Expanded and the dock SizedBox), and keep the existing >=50% clamp. Persistence of the size already works (project.layout.dock.size).', 'backlog', 'low', NULL, NULL, 'D-87', '2026-06-07 08:03:10', '2026-06-07 08:03:10', NULL, '63450e3c14cc719fb0da8a4a19d82c83', 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 ('06FB0TNQM7GCVGCH9RMGYHENCC', 'story', NULL, 'output and log panel', 'Bottom panel or context tab showing daemon logs, build output, extension logs, and pql sync output. Filterable by source. Auto-scrolls to latest. Useful for debugging extension and IPC issues.
UX design (2026-06-06, D-87): the panel is a bottom OUTPUT DOCK, read-only, two tabs — Output (the Logger stream, filter by source/level/text, auto-scroll) + Problems (moved out of the sidebar; no duplication). Toggled by a single status-bar widget that REPLACES the app-status indicator (merged health+log: green check when clean, warn/error counts when not, chevron for open state) — opens with click or Cmd/Ctrl+J. Needs a bounded in-memory ring sink on the Logger (no history today). Layout amends D-47 (dock pushes Claude up, capped so Claude stays >=50%). Terminal is NOT in the dock — kept first-class in the editor pane, tracked by T-258. Resolves Q-28. Wireframe: docs/design/wireframes/output-dock/.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-07 08:03:17', NULL, '411f3d80c7dba30b330678d58a27a74f', 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 ('06FB0TNQM4QW8VH454J8V3E174', 'story', NULL, 'Full-screen lightbox overlay (zoom + pan) for image cards', 'Click an inline image card (T-249) to open it in a full-screen overlay with zoom + pan — the standard lightbox UX from other apps (dimmed backdrop, image fit-to-screen, scroll/pinch to zoom, drag to pan when zoomed past fit, Esc or backdrop-click to dismiss).
Build the overlay as a SHARED PRIMITIVE WIDGET, not image-specific. A reusable zoomable-viewer / lightbox primitive under lib/widgets/ (e.g. ClideLightbox + ClideZoomPanViewer) that takes any child/content; the image card is its first consumer, and the canvas, graph, and diff/image previews can adopt it later. Own-the-rendering-stack + prefer-zero-deps still apply: Flutter''s SDK InteractiveViewer is the SDK-first starting point (allowed), but keep the gesture/zoom logic in the primitive so we control the UX, not an opinionated package.
Scope:
- Shared primitive (lib/widgets/): a full-screen overlay with a dimmed backdrop hosted via the existing DialogRouter (single-at-a-time modal, D-78 interaction surface). Content scales to fit on open; zoom via scroll wheel / pinch / +- ; pan by drag when zoomed in; double-click toggles fit<->100%/2x; Esc and backdrop-click dismiss. Min/max zoom clamp. Keyboard + AT reachable (ClideTappable / Semantics), theme tokens / ui-design for any chrome (close affordance, zoom hint).
- Image card wiring (T-249): the inline card stays display-only per D-78 — the click is a navigation gesture that opens the lightbox, not an inline control. Render the same resolved file at full resolution in the overlay; missing-file degrades to the existing placeholder.
- Parity (D-6): a CLI counterpart to open an image straight into the lightbox (e.g. clide image show <path> --fullscreen, or clide image open <path>), registered so it surfaces in clide capabilities (T-248).
Refs: D-78 (interaction zone / display-only conversation widgets), D-6 (CLI/UI parity), DialogRouter (lib/kernel/src/dialog.dart). Builds on T-249 (image card + clide image show). Related: T-248.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-06 08:28:43', '2026-06-07 08:32:36', NULL, '050945fb60f781edbc0a56552a3e5324', 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 ('06FB0TNQM63WP564EZRERJHVCM', 'story', NULL, 'Animate the ''running…'' turn indicator with rotating status verbs', '## Problem
While a Claude turn is in flight, the composer swaps in a static, muted
`running…` label next to the Stop button (`lib/builtin/claude/src/claude_composer.dart:486`,
shown when `widget.busy && widget.onInterrupt != null`). It''s gray, motionless,
and a little lifeless — it gives no sense that anything is actually happening.
## Goal
Make the in-flight indicator feel alive:
1. **Animation.** Add subtle motion — e.g. an animated ellipsis (`running` → `running.`
→ `running..` → `running...`), a shimmer/pulse on the text, or a small spinner glyph.
Custom-painted / token-driven per the "own the rendering stack" guardrail; no
opinionated animation packages.
2. **Rotating status verbs.** Cycle through playful gerunds the way the Claude Code CLI
does (e.g. "Clauding…", "Flibbertigibbeting…", "Pondering…", "Conjuring…"). Pick a
word from a curated list and rotate it every few seconds while the turn runs.
## Open question — can we reuse the CLI''s words?
The Claude Code CLI ships its own list of these status verbs. Before hand-rolling our
own, check whether that list is something we''re allowed to reuse / surface (licensing,
where it lives, whether stream-json exposes the current one). If we can''t pull the CLI''s
list, ship our own curated, on-brand list instead. Document the decision.
## Notes / constraints
- Respect reduced-motion / accessibility settings — animation must be disable-able and
the a11y semantics should still read sensibly (the Stop button already carries the
interrupt hint).
- Use theme tokens for color; keep it muted/tasteful, not distracting.
- Touch point today is the `widget.busy` branch in `claude_composer.dart`; consider
whether the rotating-word state belongs there or in the session/orchestrator layer.
- Add a widget/golden test for the animated states (bounded pumps — no real timers).
Refinement (2026-06-07): open question resolved — ship OUR OWN curated verb list, do not reuse the CLI''s. Rationale: the spinner words are a TUI cosmetic not surfaced by the stream-json control protocol (so there''s nothing to read live), and extracting Anthropic''s bundled list is a licensing gray area. A clide-owned list aligns with ''own the rendering stack'' and D-75 (isolate/version-pin CC coupling). State lives in the WIDGET layer (a RunningIndicator in lib/builtin/claude/), not the orchestrator — it''s ephemeral UI. Animation via AnimationController (no Timers, so tests use bounded pumps); reduced-motion (MediaQuery.disableAnimations) shows a static verb. Ready to implement.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:56:23', '2026-06-07 08:51:08', NULL, '83072782536ca8de71773dab1e5c4a41', 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 ('06FB0TNQM74JAV0MJMB4VQNTRG', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken.
Fix: /clear in the PRIMARY pane should empty the deterministic session in place — delete its <id>.jsonl (and sidecar <id>/ dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '62386d2e15f2218ba886babfb01b37ae', 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 ('06FB0TNQM6P9YQ5V7WS6RFJNHW', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions.
Two compounding causes:
1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) — it returns the old repo''s session for the new repo.
2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) — so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation.
Fix (T-269):
- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh.
- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind.
- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary — a switched workspace starts like a fresh launch.
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '2cc07f04fac1c3a563e818eac8f8a8e2', 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 ('06FB0TNQM4XBRZJ6DEHFWREWC0', 'task', '06FB0TNQM551VZ2NYV6PD0XQRC', 'Edit menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Edit top-level menu to the hat-bar application menu (T-48). Items (undo, redo, cut, copy, paste, select-all, find/replace) reference registered command ids and route to the focused surface; inapplicable items render disabled (greyed), per T-48''s show-but-disable context behavior. Depends on focused-surface command routing so the menu reflects the active editor/pane. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:32', '2026-06-07 10:42:32', NULL, '6f53d58d5090926757e63498d3a7d96d', 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 ('06FB0TNQM67PDXAFTPPW7B1WDR', 'task', '06FB0TNQM551VZ2NYV6PD0XQRC', 'Selection menu (hat-bar menu bar) — routed to focused surface', 'Deferred from T-48 v1. Adds the Selection top-level menu to the hat-bar application menu (T-48). Selection operations reference registered command ids and route to the focused surface; inapplicable items render disabled per T-48''s show-but-disable context behavior. Depends on focused-surface command routing. Reuses T-48''s menu widgets, command->item mapping, and keyboard/a11y model.', 'backlog', 'medium', NULL, NULL, 'D-7', '2026-06-07 10:42:38', '2026-06-07 10:42:38', NULL, '5b1ac6bba233c1b0c30c1947dd023377', 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 ('06FB0TNQM40JE9MZZWCXC91THG', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'done', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:43:18', NULL, '0a09af66fc3d2a61d4cc7837fe86127f', 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 ('06FB0TNQM551VZ2NYV6PD0XQRC', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.
## Refinement (decisions, 2026-06-07)
**v1 scope:** File, View, Help/About only. Edit and Selection are deferred to follow-ups [[T-271]] (Edit) and [[T-272]] (Selection) — both blocked on this story, since they need focused-surface command routing.
- View submenu: panel toggles, zoom, focus mode.
- Help: About modal (version, license, links).
**Command mapping (hybrid):** A hand-authored menu tree defines curated placement — ordering, grouping, separators, and which commands sit where. Any registered command not explicitly placed auto-fills from the command registry (by category) into the matching submenu / an overflow section, so newly registered commands surface without manual wiring. Each item''s title + keybinding are pulled from the registry.
**Context behavior:** Items reflect the focused surface; inapplicable items render disabled (greyed), not hidden. Mainly exercised once Edit/Selection land, but View/File items honor it too where relevant.
**Keyboard / a11y:** Full keyboard support — Alt+mnemonic opens a menu, arrow-key navigation within, Enter activates, Esc / click-away closes. Must meet the a11y contract (keyboard nav + semantics).', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 15:40:06', NULL, 'c61a7b26026c9727506b5ec163c1af94', 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 ('06FB0TNQM71P14PAF7K4QE61G0', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Merge tool-call + success result into one card with header status check', 'Today a Claude tool call renders as TWO stacked cards in the conversation log: the tool-use card (e.g. "Write <path>" with a collapsible call body) and a separate success/result card ("Write · result / File created successfully..."). Collapse the successful pair into ONE card; failure keeps the current two-card interaction.
Current code: lib/builtin/claude/src/conversation_view.dart — _toolUse (~L294) renders AssistantToolUse, _toolResult (~L329) renders ToolResultMessage; the two ConversationItems are linked by toolUseId (ToolResultMessage.isError marks failure). ConversationCard header is built in lib/builtin/claude/src/conversation_card.dart _header (~L200). Builds on T-168 (per-tool body rendering).
Desired behavior (success):
- One card per successful tool call — the tool-use card. The standalone success result card is suppressed (folded in), not rendered separately.
- A success-green check mark sits at the RIGHT END of the card header row (reuse tokens.statusSuccess + a check glyph: PhosphorIcons.check or CheckIcon). No new theme tokens.
- Collapsed by default: header shows the existing summary + the check. Expanding the card reveals the CALL segment (the input — path / diff / command, as today) and, BELOW it, the RESULT segment (the swallowed output). This applies to ALL tools, not just Write/Edit: for Bash/Read/Grep/LS the real output relocates into the expandable body so nothing is lost; for Write/Edit the trivial confirmation folds in the same way.
- Render the result segment as an inline ClideCodeBlock (lib/widgets/src/clide_code_block.dart) WITH editor syntax colorization. ClideCodeBlock already colorizes via TreeSitterService when handed a language — so the work is inferring/passing the right language per tool: Read → from the file extension/path (language_map.grammarForPath), Bash → bash, Grep/LS/others → text fallback. Where a path is available, prefer it so highlighting matches the editor.
Desired behavior (failure) — UNCHANGED:
- Leave the current interaction as-is: the tool-use card plus the prominent red "· error" result card (borderColor statusError, expanded-by-default). Optionally show a red status mark on the tool-use header for symmetry, but do NOT fold the error into one card.
What to build:
1. ConversationCard: a trailing header status slot (e.g. status: success|error|none) rendered in _header between the Spacer/summary and the hover action buttons.
2. ConversationCard: support a second body segment so an expanded card can show CALL then RESULT with a clear visual separator / sub-label (e.g. a divider or a muted "result" label) so the user can tell the call from its output.
3. conversation_view: a result-by-toolUseId lookup (reverse of the existing toolUseById) so a tool-use card knows its outcome; on a successful pair, stamp the check + fold the result body and SUPPRESS the standalone success ToolResultMessage from the rendered list. Errors render both cards as today.
4. Per-tool result language inference for the folded code block.
Edge cases to handle:
- In-flight tool-use with no result yet: render the call card as today (no check, no folded result); status/result appear once the result arrives.
- Orphan result with no paired tool-use: keep rendering it standalone.
- Permission-resolved tool-use cards (conversation_view.dart ~L297, already green/red bordered + collapsed): reconcile so the merged-card + check treatment is consistent and not duplicated with the existing border-outcome styling.
- Result items may not be strictly adjacent to their tool-use in the list; suppression must be keyed by toolUseId, not list position.
Tests: unit/widget coverage for the merged success card (collapsed shows check; expanded shows call + colorized result), language inference per tool, the unchanged error path, and the in-flight/orphan cases. Add a golden for the merged success card.
Refs: D-78 (interaction zone / display-only conversation widgets). Builds on T-168 (per-tool tool-use/result body rendering).
IMPLEMENTATION NOTES (from streamlining analysis):
C — Error-card symmetry decision: this ticket merges only SUCCESS into the tool card; the error path intentionally stays a separate prominent red "· error" card (conversation_view.dart ~L337) for failure visibility. Decide explicitly: keep that asymmetry (success folds, failure stays two-card) OR also stamp a red status mark on the tool-use header for visual symmetry while still keeping the separate red card. Default leaning: keep the separate red card; optionally add the red header mark.
D — Activity-cluster coupling: activity_cluster.groupConversation currently folds a tool CALL and its RESULT as TWO separate foldable items into a cluster (activity_cluster.dart ~L100-109, classifying each by toolUseId). Once this ticket makes call+result a single self-contained card, the grouping pass must treat the tool call as ONE unit (its result is part of the card, no longer a separate foldable item) or the result will double-render (once folded into the card, once as a cluster item). Update _isFoldable / the pairing logic accordingly and add a test that a merged tool card is not double-counted.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:10:42', '2026-06-07 17:41:11', NULL, 'e62becebc27914b830a058e34cf7756e', 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 ('06FB0TNQM5W6VN98RQM6S22X28', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Claude bottom status bar often stays empty / doesn''t update', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
Likely suspects to investigate:
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:26:13', '2026-06-08 07:46:46', NULL, 'd5c2cfe56ffde42485efa9eb596c0610', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-08 07:53:32', NULL, '61e4e273c9a2f876ba02b8ba3299c90e', 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 ('06FB0TNQM60PAS5RJ55CZGPM1G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface pql labels in the clide tickets UI (render + label navigator)', 'clide doesn''t surface pql ticket labels, so a rolling ''UI tweaks'' tracker has to be modeled as an epic (T-276) instead of a label — which is the cleaner primitive. Make labels first-class in the tickets UI so a label can BE the tracker.
Scope:
- Render a ticket''s labels on its card and detail view in the tickets panel (lib/builtin/tickets) — small chips/pills, ui-design tokens, no hardcoded hex.
- A label navigator: filter/group the board by label (a sidepane affordance or a filter control on the tickets panel) so ''show me everything labelled ui-tweak'' is one click. Reuse the existing filter-box / MessageBus addressability (T-270) where it fits.
- Read/write labels via the pql wrapper only (D-3 — wrap, don''t duplicate); pql already supports the ''ticket label'' verb and exposes labels on records.
- CLI/UI parity (D-6): label add/remove + filter should each have a clide verb counterpart.
Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to a label (e.g. ''ui-tweak''); file that migration as a follow-up — do NOT auto-close T-276 (it''s marked never-close).
Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', 'backlog', 'medium', NULL, NULL, 'D-3', '2026-06-08 08:32:30', '2026-06-08 08:34:47', NULL, 'f5b713e5222e00e1a8074a0abead336b', 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 ('06FB0TNQM7XXSERDHKR0EEH3N4', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Fold sub-agent prompt into the Agent card; relabel from you to agent prompt', 'When Claude launches a sub-agent (the Agent/Task tool), the sub-agent conversation runs as a SIDECHAIN. The prompt Claude wrote for that sub-agent comes through the transcript as a UserMessage with isSidechain=true but injected=false. The conversation view does not look at isSidechain, so it renders that prompt with the blue "you" label exactly like real user input — FALSELY implying the user sent it. It sits as a standalone block below the Agent tool-use card. This is misleading: the user did not write that prompt.
Confirmed facts from the code: every transcript envelope carries BOTH isSidechain AND parentUuid (transcript_reader.dart ~L547 parses isSidechain but never USES it; parentUuid is dropped entirely). The Agent/Task tool-use is rendered by _toolUse in lib/builtin/claude/src/conversation_view.dart (only AskUserQuestion is special-cased today). The "you" vs "context" label is chosen in conversation_view.dart (~L200): UserMessage with injected=true → "context" (muted); else → "you" (blue, tokens.globalFocus). isSidechain is currently never read by any rendering/grouping logic.
Design (confirmed with user):
1. Never label a sidechain prompt "you". A UserMessage with isSidechain=true is an AGENT PROMPT, not user input — label it "agent prompt" with muted / de-emphasized styling (same precedent as injected "context" messages, D-78). Never the blue "you" treatment.
2. Fold the prompt INTO the Agent tool-use card above it, COLLAPSED BY DEFAULT. The Agent card becomes the container that owns its prompt: expand the card to reveal the prompt; collapsed (default) it is hidden. The standalone prompt block is SUPPRESSED from the list — same fold-a-standalone-item-into-its-card pattern as T-262.
3. Prompt folds into the CALL, not the results. Only the prompt folds into the collapsed Agent call card. The sub-agent work (its thinking / tool calls / results) stays VISIBLE as the results chain after the call — not buried. Shape: [collapsed Agent call, owning the prompt] then [the run results], with no misleading "you" block anywhere. Agent prompts are valuable enough to keep with the call rather than fold into the subsequent results chain.
4. Link the prompt to the RIGHT Agent card via parentUuid. Parse parentUuid (currently dropped) so each sidechain prompt attaches to its spawning Agent tool-use. This matters when several agents run in parallel in one turn — a positional "nearest preceding Agent" heuristic would misattach; the parent link is the robust route. Heuristic only as a fallback when the link cannot be resolved.
Touch points:
- transcript_reader.dart: capture parentUuid from the envelope and expose it on ConversationItem (it is present in the JSONL, just not parsed).
- conversation_view.dart: relabel sidechain UserMessage to "agent prompt" (stop the "you" path); fold it into the Agent card body; special-case the Agent/Task tool name (today only AskUserQuestion is special-cased); suppress the standalone block.
- activity_cluster.dart: ensure the suppressed prompt is not double-counted in grouping; leave the sidechain results in the chain.
Edge cases:
- Parallel agents in one turn → each prompt attaches to its own card (the reason for parentUuid).
- Orphan prompt (no resolvable parent) → still render as "agent prompt", muted/collapsed, never "you".
- Nested / background sub-agents.
Tests: unit/widget coverage for sidechain-prompt relabel (never "you"), folding into the Agent card (collapsed hides, expand reveals), suppression of the standalone block, parentUuid attachment with parallel agents, and the orphan fallback. Add a golden for the Agent card with a folded prompt.
Refs: D-78 (interaction zone / display-only conversation widgets; injected/context muting precedent). Related: T-262 (fold-standalone-item-into-its-card pattern).
IMPLEMENTATION NOTE (E — Agent-card layered ordering, from streamlining analysis):
With this ticket (fold prompt) and T-262 (merge success result) both landing, the Agent/Task card ends up owning multiple segments: the tool INPUT, the folded PROMPT, and — via T-262 — the sub-agents final returned RESULT (the Task ToolResultMessage). Define a deliberate layered order when expanded (e.g. call/input → prompt → returned result) with clear sub-labels/dividers so the Agent card stays readable and does not become a kitchen sink. Coordinate with T-262 (result merge) and T-264 (nesting the whole run): the nested run region vs the returned-result segment must not duplicate the sub-agent output.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:34:02', '2026-06-08 08:45:23', NULL, 'ee9a4aa047db942114d25f30afdfc7e3', 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 ('06FB0TNQM45HHRBCRMV2166Y6M', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Restyle activity/holder card as a container of sub-cards (fix collapse-control scroll race)', 'Restyle the activity card (and, via a shared primitive, the nested sub-agent run from T-264) so a folded run reads as ONE container that holds its sub-cards, with a reachable collapse/expand affordance that survives live writes.
DESIGN RESOLVED (with user) — supersedes the earlier design-pending note.
1. HOLDER = a shared container primitive. Extract one container widget (e.g. a ClideHolderCard / conversation-level container) that renders a titled/attributed frame WRAPPING its child sub-cards. Both the activity card (_ActivityCard, lib/builtin/claude/src/conversation_view.dart ~L427) and the nested sub-agent run (T-264) consume it, so the container model is settled once. NOTE: this makes T-266 a DEPENDENCY of T-264 (the shared primitive must exist before the agent-run nesting can consume it) — update epic T-267 sequencing: T-266''s primitive lands before/with T-264, no longer strictly last.
2. Toggle = the HOLDER BACKGROUND, not the children. Clicking the container''s own background/chrome (its padding, the gaps between sub-cards, its gutter — any region NOT covered by a child) toggles collapse/expand. Crucially:
- Taps on a child sub-card lying on top of the background MUST NOT toggle the holder. Child cards opaquely consume their own hit region across their FULL bounds (including their body), so a click on a sub-card interacts with that card, never the holder.
- The copy button (and any child control / ClideTappable) keeps its own interaction — never swallowed by the holder toggle.
- Implementation: a background gesture target behind the children that only fires for hits the children do not consume (HitTestBehavior; children opaque over their bounds). No whole-card overlay that would intercept child taps.
3. Autoscroll stays ALWAYS-FOLLOW. Keep the current tail-follow (conversation_view._onChanged ~L119 jumps to maxScrollExtent on every change). Reachability of the collapse control no longer depends on getting to a top header: because the toggle is the ever-present holder BACKGROUND, a click on whatever background area is currently in view (near the latest content while following) collapses the holder — ending the race. Once collapsed the card shrinks to its ticker, so the scroll-yank stops.
4. Keep the collapsed ticker. Collapsed (default) still shows the one-line live ticker (latest step) + step count, as today. Expanded shows the contained sub-cards.
5. Accessibility (obligation): a background tap is not keyboard/AT reachable on its own, so keep an EXPLICIT focusable, Enter/Space-activatable collapse control (e.g. the ticker row remains a Semantics button / a focusable caret) in addition to the background-click affordance. Announce expanded/collapsed + step count (as _ActivityCard does today).
Touch points:
- New shared container primitive (extracted from _ActivityCard).
- conversation_view.dart: _ActivityCard adopts the primitive; background-toggle wiring; ensure child ConversationCards opaquely consume their bounds so they do not bubble taps to the holder.
- T-264 consumes the same primitive for the nested agent run.
Edge cases:
- A holder whose child is itself collapsible (a tool card): tapping the child''s caret/body toggles the CHILD, never the holder.
- Text selection inside a child (ClideSelectionArea wraps the list): selection drags on child bodies must not be hijacked by the holder toggle — another reason the holder responds only to its own background, and only to taps (not drags).
- Empty / single-item holder.
Tests: holder background tap toggles; tap on a child sub-card does NOT toggle the holder; copy button still copies (not swallowed); keyboard/AT path toggles via the explicit control; collapsed ticker + step count preserved. Add a golden for the holder container (collapsed + expanded).
Refs: D-78. Provides the shared container consumed by T-264. Related: T-230 (activity card), T-262 / T-263 / T-265. Parent: T-267.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:41:13', '2026-06-08 09:04:12', NULL, '3cb18c97d0640b5f61c494d258aa3ef2', 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 ('06FB0TNQM5YYMMKFD5Z8WG9F60', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Nest the whole sub-agent run under its Agent card (attribute sidechain via parentUuid)', 'Completion of T-263. T-263 folds the sub-agent PROMPT into the Agent card, but the sub-agent run itself — its prose, thinking, and tool calls — still renders inline in the MAIN conversation chain. A reader cannot tell where the sub-agent run ends and the main thread resumes; sidechain items are visually indistinguishable from main-thread items.
Root cause: the conversation is a flat list of ConversationItems rendered by _ConversationTurn (lib/builtin/claude/src/conversation_view.dart build ~L130, _ConversationTurn ~L182). isSidechain exists on every item (transcript_reader.dart ~L46) but no rendering/grouping logic reads it. activity_cluster.dart groups consecutive meta runs but is sidechain-agnostic.
Design: use the parentUuid link (parsed in T-263) to group an ENTIRE sidechain run as a nested region OWNED by its spawning Agent card. The run renders as a distinct, attributed, collapsible region (indent / attribution band / nested holder) under the Agent call rather than as loose cards in the main chain. Per the user rule from T-263, the run results stay VISIBLE (not folded away) — this is about attribution and containment, not hiding.
Scope:
- Build a sidechain-aware grouping pass (extend or complement activity_cluster.groupConversation) that collects all items sharing a sidechain ancestry (via parentUuid chain back to the Agent tool_use) into one nested group.
- Render that group as a nested region under the Agent card (visual indent / attribution), keeping the sub-agent results first-class within it.
- Handle parallel agents (each run nests under its own card), nested sub-agents, and background agents.
- Orphan sidechain items (no resolvable parent) degrade to the current inline rendering but still attributed (see T-264/relabel work).
Depends on T-263 (parentUuid parsing + Agent card as container). Pairs with the sidechain-prose relabel ticket. Note the holder-card restyle + auto-scroll race is a separate design discussion (see the holder-card ticket).
Refs: D-78. Builds on T-263. Related: T-262, T-230 (activity card grouping).', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:40:25', '2026-06-08 09:25:50', NULL, '44e706a4dbbffb4d53ceef9236d38955', 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 ('06FB0TNQM4Q1Q97CHA0XNK2G4W', 'story', '06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'Relabel sidechain assistant prose/thinking — attribute to agent, not claude', 'Mirror of T-263 on the OUTPUT side. A sub-agent runs as a sidechain; its assistant prose renders with the label "claude" and its thinking with "thinking", identically to the MAIN assistant — because the label logic does not check isSidechain. So a sub-agents output is presented as if the main Claude said it. Same misattribution T-263 fixes for the prompt (the "you" problem), mirrored on the assistant side.
Current code (lib/builtin/claude/src/conversation_view.dart _ConversationTurn build ~L221): AssistantTextMessage → ConversationCard(accent: claudeAccent, label: "claude"); AssistantThinkingMessage (~L227) → label "thinking". Neither inspects ConversationItem.isSidechain (transcript_reader.dart ~L46), which is present but unused.
Design: when isSidechain is true, attribute the assistant items to the AGENT rather than the main Claude — e.g. label "agent" (or the agent/sub-agent name where resolvable) with de-emphasised styling, NOT the coral "claude" brand accent (claudeAccent is the main-thread brand, reserved for the real Claude). Thinking inside a sidechain attributed/muted likewise.
Scope:
- Branch the AssistantTextMessage / AssistantThinkingMessage rendering on isSidechain.
- Pick the sidechain label + styling (distinct from "claude"; reuse muted tokens, not claudeAccent).
- Keep behavior identical for main-thread items.
Tests: relabel of sidechain assistant prose/thinking (never "claude"/coral); main-thread unchanged; golden for an attributed agent prose card.
Pairs with the nest-the-agent-run ticket (attribution + containment) and T-263 (prompt side). Refs: D-78.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:40:33', '2026-06-08 10:09:14', NULL, '161a4dd3976932afe0b038539d846c86', 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 ('06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'epic', NULL, 'Conversation rendering streamlining — fold, attribute, contain', 'Home for the cohesive set of work that streamlines how the Claude conversation log renders, so a heavy agent turn reads clearly and nothing is mislabeled or duplicated. Three recurring moves bind these tickets: (1) FOLD a redundant standalone item into its owning card (success result → tool card; agent prompt → agent card); (2) fix MISLEADING ATTRIBUTION (sub-agent prompt shown as "you"; sub-agent prose shown as "claude"); (3) settle the CONTAINER / interaction model (holder card that contains sub-cards, nested agent run, autoscroll-vs-reach-the-control).
Common substrate across the children: the flat ConversationItem list and its renderers in lib/builtin/claude/src/conversation_view.dart, the pure grouping pass in activity_cluster.dart, the ConversationCard template in conversation_card.dart, and the so-far-unused ConversationItem.isSidechain / parentUuid transcript fields (transcript_reader.dart).
Children: T-262 (merge tool-call + success result into one card with header status check), T-263 (fold sub-agent prompt into the Agent card; relabel you → agent prompt), T-264 (nest the whole sub-agent run under its Agent card via parentUuid), T-265 (relabel sidechain assistant prose/thinking — agent, not claude), T-266 (restyle activity/holder card as a container of sub-cards + fix the collapse-control scroll race). Sequencing: T-262/T-263 first, T-264/T-265 build the sidechain story, T-266 settles the shared container model last.
Refs: D-78 (interaction zone / display-only conversation widgets). Built on T-168 (per-tool body rendering) and T-230 (activity card).
SEQUENCING UPDATE (after T-266 refinement): the shared container/holder primitive now lives in T-266 and is CONSUMED by T-264 (nested agent run), so T-266 is no longer "last" — its primitive lands before/with T-264. T-264 is now blocked by T-266. Revised order: T-262 / T-263 (fold success result, fold agent prompt) → T-266 (shared holder/container primitive + activity-card restyle) → T-264 (nest the whole agent run on that primitive) → T-265 (relabel sidechain prose) can land anytime alongside.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:42:53', '2026-06-08 10:09:21', NULL, 'bde40f65dd3bb674325ecfaca7e9befa', 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 ('06FB0TNQM55CV3VDYS88VBJZ60', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'turn indicator font color → Claude accent orange', 'Change the animated running/turn indicator''s text color to the Claude accent orange (the brand accent), instead of its current color. Lives in lib/builtin/claude/src/running_indicator.dart (the rotating-verb turn indicator, T-255). Use the appropriate accent token from SurfaceTokens (ui-design skill: confirm which token maps to Claude accent orange) — no hardcoded hex. Update/adjust any golden or widget test that asserts the indicator color.', 'done', 'low', NULL, NULL, NULL, '2026-06-07 17:39:10', '2026-06-08 11:44:27', NULL, 'cf11d45286b6475ce98a771026d8b53f', 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 ('06FB0TNQM59DG2WADJ9A0JFD1W', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clearer parent/child nesting in the tickets panel (tree layout, bold focused ticket)', 'The tickets panel shows a ticket''s parent inline as ''T-273 ← T-276'', which reads ambiguously (which one am I looking at? what''s the direction of the arrow?).
Replace it with a small tree/indented layout that makes the hierarchy obvious, e.g.:
T-276
└ T-273
Requirements:
- Parent on top, the current/child ticket indented below with a tree connector (└ / ├ style box-drawing or an equivalent indent + guide).
- The ticket the user is actually looking at (the focused/opened one) is bold; ancestors are normal weight (and ideally muted/secondary).
- Support more than one level if a chain exists (initiative → epic → story → task), indenting each level; at minimum handle the common 2-level parent→child case.
- Make each id in the chain clickable to navigate to that ticket (ties into the reader/board navigation; relates to T-279 cross-ref linking).
- ui-design tokens for the guide lines + bold/muted weights; no hardcoded hex.
Scope: tickets panel rendering — lib/builtin/tickets/. See attached screenshot for the current ''T-273 ← T-276'' card style being replaced.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 11:46:11', '2026-06-08 11:49:36', NULL, 'bf04e28befe771ec1a55fc44108cabb6', 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 ('06FB0TNQM6K1QRC911JMQ9C960', 'bug', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Resume hang has no timeout/fallback; resume decided by file-exists not content', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause.
(a) No timeout / no fallback on the init-event wait.
On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume <id>'' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · <id>'' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide.
(b) Resume is decided by file existence, not resumable content.
claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a).
Acceptance:
1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery.
2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript.
3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count.
4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume.
Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257.
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-08 13:44:57', NULL, '1b3f27ac668b052d30b95ec8d98e38b4', 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 ('06FB0TNQM6A6A56VFSVRRBKMP0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable T/D/Q/R cross-refs in the Claude conversation view → open in context-pane readers', 'In the Claude conversation view, linkify governance/ticket references so they''re clickable and open the matching record in the correct context-pane reader.
Patterns to detect in rendered message text: T-NNN (pql tickets), D-NNN (decisions), Q-NNN (open questions), R-NNN (rejected alternatives).
Behaviour:
- Render each match as an inline link (ui-design tokens, link affordance consistent with other conversation links; do not linkify inside code spans/blocks).
- On click, open the record in the proper context-pane reader:
- T-NNN -> tickets reader/panel (the ticket detail).
- D-NNN / Q-NNN / R-NNN -> decisions reader, scrolled to the D/Q/R record under governance/decisions, /questions, /rejected.
- Reuse the existing reader-open plumbing rather than inventing new routing: ReaderNav / the ui.open verb / MessageBus reader addressing (T-186, T-231, T-233) and the decisions + tickets panels.
- D-6 parity: opening a record by id should already (or also) be reachable via a clide verb.
Scope notes:
- Lives in the conversation rendering path (lib/builtin/claude/src/conversation_view.dart + conversation_card.dart + the markdown/text renderer it uses). Relates to the conversation-rendering epic T-267 — could move there if preferred.
- Resolve id -> source: tickets via the pql wrapper (lib/src/pql), governance records via the decisions index/reader.
- Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash).
Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 14:12:46', NULL, 'bde704ef6fbb232fbe17ce5d26c3998c', 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 ('06FB0TNQM4KFHAS1WZFGBJ6JN4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideMarquee ignores MediaQuery.disableAnimations (reduced-motion + pumpAndSettle hang)', 'Two animation mechanisms exist and are inconsistent. The turn/running indicator (running_indicator.dart, T-273) gates its AnimationController on MediaQuery.disableAnimations and stops when reduced-motion is set. ClideMarquee (clide_marquee.dart, the status-bar footer scroll, T-150/T-160) drives a raw Ticker whenever its content overflows, with NO disableAnimations check and no flag.
Two costs:
1. Accessibility — a reduced-motion user still gets the scrolling footer; the marquee ignores the OS reduce-motion setting the turn indicator honors.
2. Testing — the marquee''s perpetual Ticker is the historical pumpAndSettle-hang culprit; its own tests cope by only ever calling pump()/pump(Duration), never pumpAndSettle.
Fix: unify on the one mechanism — make ClideMarquee honor MediaQuery.disableAnimations. When reduced motion is set, don''t start the ticker (stop + reset offset to 0 if running), and render the child statically (clipped) instead of looping. Read the flag in didChangeDependencies like running_indicator does. Then both animations obey one flag, the marquee becomes reduced-motion-correct, and it stops being a pumpAndSettle landmine.
Acceptance:
1. With MediaQuery(disableAnimations: true), an overflowing ClideMarquee does not scroll and pumpAndSettle completes (no hang).
2. With the flag off, overflow still scrolls as today.
3. Toggling the flag at runtime starts/stops the scroll.
4. Test covering the reduced-motion (no-scroll, settles) case.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 14:55:43', '2026-06-08 14:57:15', NULL, '97144d20a2edf1911ee298421699fcc8', 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 ('06FB0TNQM6GZ55VD4SAZZ6XXP4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation ListView items lack keys — card state mis-associates when results stream in', 'The conversation ListView.builder (conversation_view.dart:257) builds its items (_ConversationTurn, _ActivityCard) with NO keys. Each resolves to a stateful subtree (ConversationCard: _collapsed/_hover/focus nodes, with _collapsed set once and never re-synced in didUpdateWidget; ClideHolderCard owns cluster expand state). In a keyless list Flutter matches State to widgets by POSITION, so when the visible list reshapes, State attaches to the wrong logical card.
The list reshapes exactly when a read/write result lands: T-262 folds a successful result into its call card and suppresses the standalone result via _visibleItems, errors append a sticky card, and activity clusters re-fold. So after a tool completes, collapse/hover state — and for a folded cluster, which tools/summary it shows — can jump to the wrong card. (The success/error mark itself is widget.status, recomputed per build, so it follows the widget; the stateful bits are what mis-associate.)
Fix: give each ListView item a stable ValueKey from its identity — sticky item by item.uuid, folded cluster by its first item''s uuid (namespaced so sticky vs cluster can''t collide) — plus add super.key to the _ConversationTurn/_ActivityCard constructors. Also key the nested run items in the Agent holder + activity card so sub-agent streams reshape safely.
Acceptance:
1. Streaming a tool-use then its success result keeps each card''s collapse state pinned to the right card (a card the user expanded stays expanded when a later tool completes).
2. A folded activity cluster keeps its identity/summary across reshapes.
3. Widget test: pump a conversation, toggle one card''s collapse, stream a new result that reshapes the list, assert the toggled card is still the one expanded.', 'done', 'high', NULL, NULL, NULL, '2026-06-08 15:22:32', '2026-06-08 15:37:29', NULL, 'fd360d075436a0459508bd994828b39a', 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 ('06FB0TNQM7W2MWZ45YNK7TJGC4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Collapsed conversation card has inconsistent bottom padding', 'The collapsed Agent (tool-call) card in the Claude conversation view has different padding beneath it than the spacing around all other cards — see the gap flagged below the `> Agent` disclosure in the conversation. Make the collapsed card''s bottom padding match the standard inter-card spacing used everywhere else.
Card layout lives in lib/builtin/claude/src/conversation_card.dart: the outer wrapper applies EdgeInsets.only(bottom: 14) (line ~188) and the stripe variant adds inner EdgeInsets.fromLTRB(12, 8, 12, 8) (line ~210). When collapsed, only the header renders, so the combined bottom spacing (inner 8 + outer 14) reads differently from expanded cards. Reconcile so the visible gap below a collapsed card equals the gap below other cards.', 'done', 'low', NULL, NULL, NULL, '2026-06-08 13:39:15', '2026-06-08 15:42:01', NULL, '92ea10391ed6be62981f7f4117381d6f', 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 ('06FB0TNQM5AAA9WW54JGP83HGW', 'task', '06FB0TNQM5R93N5J6RNVTBRB28', 'Frame0 design: permission-mode dropdown beside the Claude composer', 'Produce a Frame0 wireframe for a persistent permission-mode control next to the Claude conversation composer, then use it to drive implementation.
Goal: surface the current permission mode (default · accept-edits · plan; bypass behind the cockpit guard) AT ALL TIMES, not only in the bottom status bar. Move/duplicate the mode affordance to an icon button sitting to the RIGHT of the composer text box (ClaudeComposer), showing the current mode via icon (+ short label), and opening a dropdown/popover menu to switch mode on click.
Design to wireframe (frame0-wireframe skill):
- Composer row with the mode icon-button anchored at its right edge (trailing), aligned with the send/interrupt control; show resting state for each mode (distinct icon/color per mode) and the open-dropdown state listing selectable modes with the active one marked.
- Account for the interaction zone (D-78): when a permission/AskUserQuestion prompt replaces the composer, define what happens to the mode button (hidden? disabled? persists above?).
- Keep it consistent with the existing status-bar mode badge (T-226: click or Ctrl/Cmd+M cycles the safe trio) — decide whether the status-bar badge stays, is removed, or mirrors the new control.
- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons).
Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 16:52:13', NULL, 'dfbe1e3e3ea200d993d4e860da4d6769', 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 ('06FB0TNQM6NNRR61E8P6GHXABG', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Right-click context menus with context-aware actions', 'Add right-click (secondary-tap / context-menu key) handling across clide surfaces, opening a context menu anchored at the pointer.
**Build on existing primitives.** Reuse `ClideAnchoredOverlay` + `ClideMenu` (D-88, landed via T-286/T-288) for the popup — do not introduce a new menu widget. The context menu is just another content component over the shared anchored-overlay base, opened on secondary tap instead of a click target.
**Common actions (the usual suspects).** A baseline set available wherever they apply: Copy, Cut, Paste, Select All. These are driven by focus/selection state of the surface under the pointer (editor, text field, terminal selection, list item label).
**Context-aware injected actions.** On top of the baseline, each surface contributes extra menu items relevant to *what* was clicked:
- **File navigator** — clicking a file: Duplicate, Rename, Delete, Reveal, Copy path; clicking a folder: New file/folder, etc.
- **Git pane** — clicking a staged file: Unstage, Revert/Discard; clicking an unstaged file: Stage, Discard changes; clicking a hunk where applicable.
- Other panes contribute their own as the pattern proves out.
**Architecture.** Define a small contract so a surface can declare its context-menu items for a given target (the right-clicked element + its state), which get merged with the baseline edit actions. Honour user/Claude parity (D-6): each injected action should map to an existing `clide` verb / command where one exists, so the menu is a discoverable front-end to commands Claude can also invoke.
**Scope notes.** Start with file navigator + git pane as the two concrete consumers (they motivate the feature). Keyboard parity: context-menu key + Shift+F10 should open the same menu for the focused element. Accessibility: menu items reachable and labelled per the a11y contract.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 20:16:22', '2026-06-08 20:16:22', NULL, 'cc6e2f2b7a787cc55b876ce031d7a5f4', 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 ('06FB0TNQM4501EBPDFDH84RJ5G', 'task', '06FB0TNQM5R93N5J6RNVTBRB28', 'Migrate remaining anchored surfaces onto ClideAnchoredOverlay (theme picker, typeaheads, quick-open)', 'Follow-up to the D-88 sweep. The primitive (ClideAnchoredOverlay + ClideMenu) shipped and the menu bar (T-286) + T-275 picker are migrated. The theme-picker migration was attempted and REVERTED after hitting two real blockers that affect all the remaining anchored surfaces; resolve these first, then migrate theme picker, @-mention typeahead, slash typeahead, and quick-open.
Blockers found (theme picker attempt):
1. Focus-capture race. ClideAnchoredOverlay.captureFocus does a post-frame _scope.requestFocus(); ClideMenu also autofocuses its own node. Depending on mount timing (e.g. an extra ListenableBuilder wrapper) the scope wins and STEALS focus from ClideMenu''s node, so arrow/enter never reach the menu (Esc still works only via the ancestor fallback). Menu bar happens to win the race; the theme popover loses. Fix the primitive''s focus model so content''s own focus node reliably ends up focused (e.g. don''t force scope focus; or make the scope delegate to the autofocus child deterministically), then re-verify menu bar + anchored tests.
2. Follower content is not reliably mouse-tappable in the widget-test harness. The shared harness() uses Overlay(canSizeOverlay) + a zero-size MediaQuery, so an anchored follower positions the panel off-screen (observed tap offsets like (680,-45) and (680,654)) and tester.tap misses. Also autoFlip reads MediaQuery.size (zero in harness) so it can''t flip. Mitigations: have autoFlip use View.physicalSize (more correct anyway); position content without the inner Align (the panel shrink-wraps); and in tests drive selection via keyboard (as menu_bar does) or provide a real viewport. Consider a test helper for anchored-overlay content.
Then migrate, one surface per commit, keeping each existing test green (theme_picker widget_test, team_chat_sidebar_test, claude_composer_test, quick_open_overlay_test). Quick-open is centered (no follower) and the weakest-fit — it only needs the lifecycle wrapper; migrate last or skip.
Refs: D-88, T-286, T-275 (done), menu bar (done, commit e1d51eb).
RE-SCOPE (2026-06-08, per D-88 amendment): the goal is NOT ''migrate everything onto ClideMenu''. The shared base is ClideAnchoredOverlay; content matches the surface:
- ClideMenu stays scoped to selectable-list menus (menu bar + permission picker — done).
- Add a NEW shared content component ClideTypeahead for the slash + @ typeaheads (near-duplicate caret-anchored completion surfaces) — they share this, not ClideMenu. This dedups two hand-rolls.
- Quick-open keeps bespoke content (centred filter + fuzzy + two-column rows); uses ClideAnchoredOverlay base only. Weakest fit; last or skip.
- Theme picker: its own small toggle+list content on the base (or ClideMenu if it cleanly fits).
Order: (1) fix the base blockers — focus capture racing content autofocus, and follower content untappable in the canSizeOverlay/zero-MediaQuery test harness (use View size for autoFlip; add a test path for anchored content) — with tests; THEN (2) ClideTypeahead + migrate slash + @; (3) theme picker; (4) quick-open last.
DONE: Sweep complete. Base blockers fixed (View-size autoFlip; follower drops Align so it hit-tests; tests use anchoredHarness). Migrated: ClideTypeahead + slash + @-mention typeaheads (live ValueNotifier-bridged suggestions); theme picker onto ClideMenu (HC toggle = keepOpenOnSelect item). Quick-open deliberately NOT migrated — persistent centred widget sharing neither ClideMenu nor anchoring; rationale in D-88 closing amendment.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 17:52:51', '2026-06-09 06:19:01', NULL, 'c67bf2dd6b082be4de1f53b2f89b4bb3', 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 ('06FB0TNQM5R93N5J6RNVTBRB28', 'epic', '06FB0TNQM5TWC00GW0P3X02HZW', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.
DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + ClideTypeahead) shipped and exported. Migrated: T-275 permission picker, menu bar, theme picker (ClideMenu), @-mention + slash typeaheads (ClideTypeahead). Quick-open intentionally left bespoke (centred persistent widget, no shared shape) — see D-88 closing amendment. Modal session/project/branch pickers stay on DialogRouter by design.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-09 06:19:36', NULL, '05f165342aa1d3708ca2d0f6e204c23e', 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 ('06FB0TNQM44CKN5QQGFKP1KWZC', 'story', '06FB0TNQM6T6580D8ABDVTMNZW', 'Edit editor settings (.editorconfig) from the settings panel', 'Write path for editor settings (T-29 follow-up). The editor now READS .editorconfig into a source-agnostic EditorSettings model (lib/src/editor/editor_settings.dart) and resolves it via editor_settings_resolver.dart. Add a settings-panel surface to EDIT these values and persist them — initially by writing the project .editorconfig (indent_style/size, end_of_line, max_line_length, trim_trailing_whitespace, insert_final_newline), later possibly a clide-owned settings file that merges as a higher-precedence source in resolveEditorSettings. On save the registry already re-resolves open buffers (editor.settings-changed), so the panel edits apply live. Respect EditorConfig semantics (sections/globs); start with the [*] section for the workspace defaults. Refs: T-29, D-88, editor_settings.dart, editor_settings_resolver.dart.', 'backlog', 'medium', NULL, NULL, 'D-88', '2026-06-09 07:15:14', '2026-06-09 07:15:14', NULL, '4ff37d3b847a5a0cbe89bab48aa27596', 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 ('06FB0TNQM45BSF1B0R0JA1Z7FC', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Re-resolve .editorconfig on external file changes (fs-watch)', 'T-29 follow-up. Today open buffers re-resolve their EditorSettings when a .editorconfig is saved IN-APP (a hook in EditorRegistry.save → editor.settings-changed). External edits — another editor, a git checkout/branch switch — are NOT picked up until reopen. Wire a filesystem watch so an external .editorconfig change re-resolves open buffers too. Prefer piggybacking the files subsystem''s existing watcher over a new dedicated watch; debounce; only re-resolve buffers under the changed config''s directory. Rare case, hence deferred from the initial T-29 work (the in-app save hook covers the common path). Refs: T-29, lib/src/editor/editor_settings_resolver.dart, EditorRegistry._reresolveSettings.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-09 07:15:22', '2026-06-09 07:15:22', NULL, 'ac3fcdaa95194c311e19fc22aef6fd59', 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 ('06FB0TNQM5E41Q8TX8X55X4MZ0', 'story', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'editor respects .editorconfig settings', 'When a file is opened in the editor, read .editorconfig from the workspace root and apply: indent_style, indent_size, max_line_length (ruler/wrap guide), end_of_line, trim_trailing_whitespace, insert_final_newline. Parse the INI format ourselves (small, no dep). Glob matching per the EditorConfig spec.
DONE: The editor honours .editorconfig end-to-end. New source-agnostic EditorSettings model (editor_settings.dart) + composition seam (editor_settings_resolver.dart); .editorconfig demoted to a source (editorconfig.dart, own INI parser + glob matcher, root/nearest-wins precedence, no deps). Registry resolves on open and re-resolves open buffers when a .editorconfig is saved in-app (editor.settings-changed; a save hook, not an fs-watch). Editor: Tab/Shift+Tab indent + max_line_length ruler; save applies end_of_line/trim_trailing_whitespace/insert_final_newline. Follow-ups: T-290 (edit from settings panel), T-291 (external fs-watch). 100% coverage on new model/resolver/parser.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-22 20:17:26', '2026-06-09 10:46:41', NULL, '6d078026407dc764d9273b2fe9b94a70', 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 ('06FB0TNQM50BPNDT50PM24ZVA4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Slash-command typeahead breaks on ''-''; Tab-to-complete is flaky', 'Two issues in the Claude composer slash-command typeahead (T-152/T-153, lib/builtin/claude/src/slash_commands.dart + claude_composer.dart):
1. Typing a ''-'' character breaks the typeahead. Many command names contain hyphens (e.g. clear-context-style names, custom commands), but typing ''-'' appears to drop/empty the suggestion list or mis-parse the active query. Suspect activeSlashQuery''s token run or filterSlashCommands prefix matching not handling ''-'' (or the composer treating ''-'' as a boundary).
2. Tab to accept the highlighted suggestion responds flakily — sometimes it completes, sometimes nothing happens. Suspect a focus/key-handling race between the composer''s key handler and the typeahead overlay, or Tab being consumed by focus traversal before the accept intent fires.
Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-'' and watch the list; (b) arrow-select an item and press Tab repeatedly.
Acceptance: ''-'' is treated as a normal command-name character (suggestions keep filtering through hyphens); Tab reliably completes the highlighted suggestion every time (insert via completeSlash). Add/extend unit tests in slash_commands_test.dart for hyphenated queries and a composer widget test for Tab-accept.
RESOLVED by the D-88 ClideTypeahead migration (T-286), verified 2026-06-09. Root cause of both symptoms was the OLD hand-rolled overlay, not the pure helpers (activeSlashQuery/filterSlashCommands always handled ''-''): (1) the old overlay didn''t narrow live as you typed, so a ''-'' looked like it emptied/broke the list; (2) the old focus model raced Tab. Post-migration the popover narrows live (ValueNotifier) and the field keeps focus (captureFocus:false), so ''-'' filters normally and Tab reliably completes. Confirmed bare ''-'' is NOT a keybinding (zoom is ctrl/meta+minus). Added regression tests: hyphen cases in slash_commands_test; composer widget test typing through a hyphen + Tab-accept. Commit d8b9a41.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 09:20:00', '2026-06-09 15:03:23', NULL, 'b398af8597d7249428c508c6365ec3dc', 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 ('06FB0TNQM4BD8PXDRHBF001200', 'bug', NULL, 'Flaky: pty session_test ''write sends keystrokes to child'' starves under parallel/coverage load', 'test/pty/session_test.dart:47 ''write sends keystrokes to child'' (and its sibling pty integration tests) intermittently fail when run in a large parallel pool — observed twice during T-29: once in a bare ''flutter test --coverage'' (whole suite, max parallelism + coverage instrumentation) and once mid pre-push gate (''+570 -1''). Passes reliably in isolation and via the gate''s SERIAL test-core pass (''dart test test/pty'', +571 ok), so it is not a logic bug.
Root cause: these tests spawn a real /bin/sh via NativePty and wait on the reader ISOLATE to deliver bytes. Under heavy CPU contention the reader isolate is starved and the 20s ioTimeout (test/helpers/timeouts.dart) elapses before the shell''s first byte / echo response is delivered. Already hardened once (T-108 swapped wall-clock sleeps for event-driven waits; first-byte Completer + 25ms-poll _waitForBuffer), but the isolate-scheduling assumption still breaks under load.
These tests are tagged ''pty'' and are meant to run in the gate''s serial pass. Fix options: (a) ensure the parallel coverage pass EXCLUDES tag:pty (run pty only in the serial pass) so they never compete for isolate scheduling — verify ci/test.sh / dart_test.yaml tag routing; (b) harden against isolate starvation (e.g. give the reader isolate priority, or assert on delivery via a more robust signal); (c) document that ''flutter test --coverage'' over the whole tree is not a supported invocation (use make test + the gate). Prefer (a). Refs: T-108, T-96 (reader-isolate hangs), T-192 (gate parallel/serial passes), D-23 (test pyramid).
FIXED 2026-06-09 (commit 0231cb4). Root cause: ci/test_core.sh ran ''dart test test/ipc test/pty ... test/pql'' in the DEFAULT parallel pool, so test/pty''s real-PTY tests contended for fds+CPU with the other core suites and the reader isolate was starved. (ci/test.sh already isolated pty via --concurrency=1 --tags pty; test_core.sh was the gap.) My manual ''flutter test --coverage'' repro was a separate wrong-invocation artifact — the flutter runner can''t reliably deliver the PTY master fd, which is why the gate excludes pty from the flutter pool entirely.) Fix: test_core.sh now runs a serial ''--concurrency=1 --tags pty'' pass + a parallel ''--exclude-tags pty'' pass, mirroring ci/test.sh. Verified: pty pass +5 stable across repeated runs; full core +571 unchanged. Option (a) from the ticket.', 'in_progress', 'medium', NULL, NULL, 'D-23', '2026-06-09 14:57:32', '2026-06-09 15:09:05', NULL, '73fd633127cb5d51b0469e4559580db1', 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 ('06FB0TNQM5DM43Q9HX352STN3M', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide image show is not tab-aware — opens in primary tab', '`clide image show <path>` (T-249 image card) renders the image into the PRIMARY tab regardless of which tab/pane is active. It should target the active tab (or otherwise be tab-aware), not hijack the primary one.
Repro:
- Have a non-primary tab focused.
- Run `clide image show <path>`.
- Observed: image card appears in the primary tab.
- Expected: image card appears in the active/intended tab.
Notes:
- Surfaced while showing a Frame0 wireframe export during T-294.
- Likely the image-show dispatch resolves a fixed/primary slot instead of the active tab context. Audit the target-resolution path for the image-show command.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 15:26:00', '2026-06-09 15:26:00', NULL, 'fb011f536bf6c2734eb1fadc9b9ef5c6', 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 ('06FB0TNQM42WW0SY3XEXKE8PK8', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Chosen theme is not persisted per-repo across loads', 'Picking a theme (status-bar switcher T-234, or the Settings modal) applies live but is never saved, so every reload resets to the first bundled theme. The choice should persist PER REPO.
Root cause: ThemeController.select() (lib/kernel/src/theme/controller.dart:43) only mutates _currentName + notifies — no persistence. It is constructed in facade.dart:160 as ThemeController(bundled: bundledThemes) with no initialName, so on boot it always falls back to bundled.first.
Infra already exists — no new store needed: SettingsStore (lib/kernel/src/settings.dart) has a PROJECT scope that persists to <repo>/.clide/settings.yaml via ''project.*'' keys, and ProjectManager.openProject() already calls settings.setProjectDir() on open (project.dart:130), which loads that file.
Proposed fix:
1. PERSIST on change — wire a listener on ThemeController (in facade.dart, where both theme + settings exist) that writes the current theme to settings whenever currentName changes; use project scope (''project.theme'') when a repo is open. currentName already encodes the high-contrast variant (resolveThemeName -> ''<base>-hc''), so the HC toggle persists for free.
2. RESTORE on load — the project dir is set AFTER boot, so restoration must hook project-open (ProjectManager.openProject / settings.setProjectDir), not just construction: when a project opens, read ''project.theme'' and, if present + known, theme.select() it. Guard ArgumentError for a theme that is no longer bundled (fall back to default, don''t throw).
Open question: app-scoped fallback. Persist project-scoped per the report; optionally also write ''app.theme'' as the global default so a brand-new repo inherits the last choice instead of bundled.first. Decide during implementation.
Acceptance: pick a theme (incl. the High-contrast toggle) -> restart -> same theme restored for that repo; two repos keep independent choices; with no repo open, the app default applies; a saved theme that no longer exists degrades to the default without throwing.
Tests: SettingsStore round-trips ''project.theme''; opening a project applies the saved theme to the controller; select() persists; unknown saved theme falls back. Refs: T-234 (theme switcher), T-288/D-88 (picker), SettingsStore, ProjectManager.
FIXED 2026-06-09 (this commit). Added lib/kernel/src/theme/theme_persistence.dart + wireThemePersistence() in facade. Decision on the open question: persist BOTH project.theme (per-repo, in .clide/settings.yaml) AND app.theme (global default) — so a themed repo keeps its choice, an unthemed/new repo inherits the last global choice, and the HC variant persists (name encodes -hc). Restore prefers project over app; unknown theme is ignored (no throw). Tests: test/kernel/src/theme_persistence_test.dart (6 cases — persist app/project, HC, restore-on-open, boot restore, unknown fallback). All green.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 15:12:15', '2026-06-09 15:36:15', NULL, 'ea78a24a1671017d25613be8a1295ae5', 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 ('06FB0TNQM61SRVBDRFFN2S80T4', 'story', NULL, 'Render pasted images via the image viewer card in the Claude conversation panel instead of echoing only the file path', 'When the user pastes an image, the conversation panel echoes back the file path as plain text (e.g. @/home/.../paste-<ts>.png). We now have an image viewer card — render the pasted image inline using that card instead of (or in addition to) the bare path.
## Behaviour
- Detect a pasted-image path in the conversation stream and render the image viewer card.
- Still surface the file path (e.g. as a caption / subtitle on the card) so it can be copied/referenced.
- Reuse the existing image viewer card component rather than building a new one.
## Notes
- Applies to the Claude conversation panel rendering path.
- Consider failure cases: missing/deleted file, non-image paste, very large images.
CANCELLED as a duplicate of T-236 (2026-06-09). T-236 is the concrete spec for rendering pasted @path images inline in the Claude conversation; its scope now includes T-254''s path-as-caption nuance. Implement under T-236.
RETRACTED the cancellation — NOT a duplicate of T-236 (per user, 2026-06-09). Both stay open. T-254 remains its own ticket.
DONE 2026-06-09 — satisfied by D-89 together with T-236. The lightbox-expansion + path-retention (filename a11y label / unchanged copyText) intent from T-254 is delivered by the shared ImageThumbnail → lightbox; the chosen presentation is inline (not a separate card) per the user. See T-236 / D-89 / commit.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:54', '2026-06-09 15:55:06', NULL, '407e83a02647656a01c31757fec93197', 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 ('06FB0TNQM4GN07FN8GAXZ20YAR', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Render pasted-image @path tokens as thumbnails in the message log', 'When a user message contains a pasted-screenshot reference — an @<path> token pointing at an image (the composer''s ComposerAttachment.pathToken format, e.g. @/home/<user>/.cache/clide/pasted/paste-<ts>.png, see screenshot) — the conversation log renders it as the raw path string. Show it as an inline THUMBNAIL instead.
The composer already renders pre-send attachments as Image.file thumbnail chips (clipboard_paste.dart + claude_composer _chip); this carries that into the post-send message log (UserMessage rendering in conversation_view/conversation_card).
Scope:
- When rendering UserMessage text, detect @<path> tokens; for image paths (reuse the extension check behind _looksLikeImage: .png/.jpg/.jpeg/.gif/.webp/.bmp), render a bounded Image.file thumbnail in place of the bare token. Surrounding prose still renders normally; a message can carry multiple tokens.
- Click/activate the thumbnail to open the full-size image (a preview dialog). Keyboard-operable + a11y label (filename).
- Graceful fallback: a missing/unreadable file (pasted temp files can be cleaned up) degrades to a small placeholder or the path text via Image.file errorBuilder — never a crash/exception.
- Non-image @path tokens MAY render as a file chip (filename + icon) like the composer, but image thumbnails are the focus.
- Note: Image.file reads the path directly via dart:io, so this is NOT gated by the files.read allow-list (D-80) — the cache dir is outside the workspace and that''s fine for display.
- Display-only: the text actually sent to Claude is unchanged; this only affects rendering.
Acceptance:
1. A user message with an @<path> image token shows an inline thumbnail in the log (not the raw path).
2. Clicking/activating the thumbnail opens the full image.
3. A missing/unreadable referenced file degrades to a placeholder (or the path text), no exception.
4. Prose and any non-image @path tokens around it still render readably; multiple tokens in one message all resolve.
5. The message content delivered to Claude is unchanged (render-only). Relates to T-142 (paste attachments).
MERGED T-254 (duplicate) into this ticket. Extra nuance carried over from T-254: keep the file path available (e.g. as a small caption/subtitle under the thumbnail, or via the lightbox) so it can still be copied/referenced. Reuse the existing inline image card + lightbox (T-252) rather than a new component.
RETRACTED the earlier ''merged T-254'' note — T-236 and T-254 are distinct tickets (per user); disregard that note. T-236 stays scoped to its own description.
DONE 2026-06-09 — implemented as the hybrid (D-89): inline thumbnail in the message prose that opens the lightbox on click/Enter. New ImageThumbnail + openImageLightbox (lib/builtin/claude/src/image_thumbnail.dart); ClideMarkdown gained an onImageToken WidgetSpan seam. Missing file → placeholder; multiple tokens; non-image @path stays text; render-only (copyText unchanged). Composer chips reuse the same 44px thumbnail. Tests: image_thumbnail_test + conversation_view_test cases. Closes alongside T-254.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-03 15:31:01', '2026-06-09 15:55:06', NULL, 'bdd6cd8734a9a6a361cf6f128e3e632f', 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 ('06FB0TNQM6RXN6HW6XTEHKYQDG', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation doesn''t re-scroll when the bottom input resizes (content hides behind it)', 'When the bottom input area changes height, the conversation view does not re-scroll to the new viewport bottom, so the last bit of content is left hidden behind the newly-sized box.
Trigger: anything that resizes the bottom interaction zone (D-78) — e.g. opening a permission dialog or the AskUserQuestion UI, which replace/expand the composer. The taller box shrinks the conversation viewport from the bottom, but the scroll offset isn''t adjusted, so content that was at the bottom edge is now occluded.
Expected: when the interaction zone grows, the conversation re-anchors so the previously-visible bottom content stays visible above the box (preserve bottom-anchoring / keep the tail in view). Symmetric on shrink — no leftover gap when the box collapses back.
Repro:
1. Scroll the Claude conversation to the bottom (tail in view).
2. Trigger a permission prompt or AskUserQuestion (interaction zone expands).
3. Observed: a strip of the last message/card is hidden behind the enlarged input box.
4. Expected: view scrolls so that content remains fully visible above the box.
Notes:
- Likely the scroll controller doesn''t react to the composer/interaction-zone height change (no re-scroll on viewport-inset/size change). Audit the conversation view''s scroll handling around interaction-zone show/hide (D-78) and on keyboard/box resize.
- Affects the Claude conversation panel.
DONE 2026-06-09 (commit). conversation_view tracks at-bottom + a LayoutBuilder re-jumps to the tail when the viewport height changes (interaction-zone resize, D-78), only when pinned. Tests: conversation_scroll_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 16:01:09', '2026-06-09 16:34:46', NULL, '6503bbef73fb77041d324369d61ea208', 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 ('06FB0TNQM72SHY93S36VM9G6D8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Collapse buttons for sidebar and context pane', 'Design and add collapse/expand controls for the left sidebar and the right context pane, along the lines of the reference screenshot (green arrows mark the two intended affordance locations — bottom-left of the sidebar and bottom-right of the context pane).
Scope of this ticket: set up a Frame0 mock to talk through the design before implementing.
Open questions to resolve in the mock:
- Affordance placement: footer/status-bar anchored (as the screenshot arrows suggest) vs. pane-edge chevron.
- Collapsed state: fully hidden vs. thin rail with a re-expand handle.
- Iconography (chevron direction) and hover/active states.
- Whether sidebar and context pane share one control pattern (parity) or differ.
- Keyboard/CLI parity (D-6): each collapse action needs a clide verb.
Deliverable: Frame0 wireframe(s) of collapsed + expanded states for both panes, reviewed before any code.
DESIGN DIRECTION (settled): anchor both toggles on the OUTER EDGES of the center (Claude conversation) pane — one on the left edge controlling the sidebar, one on the right edge controlling the context pane. The control stays fixed on the center-pane edge whether the adjacent pane is open or collapsed, so a single button both collapses an open pane and re-opens a collapsed one (chevron flips direction). This avoids needing a separate "re-expand" handle on the collapsed pane.
Implications for the mock:
- Collapsed pane can be fully hidden (no thin rail needed) since the re-open control lives on the center edge.
- Sidebar and context pane share one mirrored control pattern (parity).
- Chevron direction reflects state: points outward to expand, inward to collapse.
IMPLEMENTATION NOTE: the collapse logic already exists — no new toggle behaviour needed. Commands `sidebar.collapse` (ctrl+shift+1) and `context.collapse` (ctrl+shift+3) are registered in lib/builtin/default_layout/src/extension.dart, exposed in the command palette + menubar, and call arrangement.toggleCollapsed(Slots.sidebar|contextPanel), returning isCollapsed (D-051, D-054).
So this ticket is scoped to the VISUAL AFFORDANCE only:
- Add the two edge-anchored toggle buttons on the center (Claude) pane''s outer edges.
- On click, invoke the existing `sidebar.collapse` / `context.collapse` commands (do NOT reimplement collapse).
- Read arrangement.isCollapsed(...) to flip the chevron direction per state.
- D-6 CLI/keyboard parity is already satisfied by the existing commands; this adds the mouse affordance.
Mock: docs/design/wireframes/hud/pane-collapse-toggles.{json,png} — State A (open) + State B (collapsed).
PLACEMENT REVISED: toggles do NOT float vertically-centered on the pane edges. They live in the BOTTOM STATUS BAR. Each toggle is horizontally pinned to the center pane''s left/right edge, so when a pane collapses the toggle slides along the status bar to that end (open: at the inner pane boundary; collapsed: at the far status-bar end — matching where the reference-screenshot arrows pointed). Still mirrored left/right for parity; chevron flips per isCollapsed. Buttons invoke the existing sidebar.collapse / context.collapse commands.
Mock updated: docs/design/wireframes/hud/pane-collapse-toggles.{json,png}.
APPROVED (2026-06-09): pinned-to-edge status-bar placement confirmed by user. Design is settled; ready for implementation.
PLACEMENT FINAL (supersedes "pinned-to-edge"): toggles are FIXED at the far-left and far-right ends of the bottom status bar in every state. They do not slide with the pane edge. Position is constant (muscle memory; always where the reference-screenshot arrows pointed); only the chevron flips per isCollapsed. Left end = sidebar toggle, right end = context toggle. Mirrored for parity. Buttons invoke the existing sidebar.collapse / context.collapse commands.
Mock updated: docs/design/wireframes/hud/pane-collapse-toggles.{json,png}.
SPACE NOTE: the status bar already hosts content at both ends (left: branch / skills count; right: Output / terminal). Fixed-end toggles therefore share that space. Resolution: reserve the OUTERMOST ~24px cell at each end for the toggle and shift the existing status items inward by that width — a constant reservation, not a dynamic fight (the toggle never moves or grows). The mock reflects this: status text begins after the left toggle and ends before the right toggle.
GLYPH DECISION: use Phosphor caret-line icons (chevron + edge line), which read as "collapse to the edge":
- caret-line-left -> 0xe132 (CaretLineLeft)
- caret-line-right -> 0xe130 (CaretLineRight)
The glyphs already ship in assets/fonts/phosphor (codepoints.csv lines 150-151) — no new dependency. Add two consts to lib/widgets/src/icons/phosphor.dart (PhosphorIcons.caretLineLeft / caretLineRight) during implementation. Chevron-line direction flips per isCollapsed.
RESOLUTION (2026-06-09): considered moving the re-open control into the center (pane edge or center status segment) to dodge the end-of-bar space contention; user chose to KEEP TOGGLES FIXED AT THE STATUS-BAR ENDS (best muscle memory). Final design = v3 mock: caret-line glyphs at fixed far-left/far-right status-bar cells (~24px reserved, side items shifted in), chevron-line flips per isCollapsed, firing existing sidebar.collapse / context.collapse. Design fully settled; ready to implement.
DONE 2026-06-09 (commit). Fixed ~24px caret-line toggles bookend the status bar (StatusbarHost), flip chevron per arrangement.isCollapsed, fire existing sidebar.collapse/context.collapse. Visual affordance only. Tests: app_collapse_toggle_test + updated app_statusbar_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 15:18:22', '2026-06-09 16:34:46', NULL, 'c138ffeffc36fcd39fbef562519377e9', 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 ('06FB0TNQM63TRGBFRWK9W2WA98', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Group consecutive edits to the same file into one collapsed card', 'When Claude makes multiple subsequent edits to the SAME file, bundle them into a single collapsed holder card instead of rendering each edit as its own card — mirroring how we already fold meta/activity runs.
Behaviour:
- Detect a run of consecutive edits targeting the same file_path and group them into one ClideHolderCard (the shared container from T-266; see _ActivityCard in lib/builtin/claude/src/conversation_view.dart:681).
- Collapsed (default): one-line ticker of the latest edit + a count label.
- Show the count as "# edits" (e.g. "3 edits" / "1 edit"), NOT "# steps". The existing activity card uses stepLabel = ''$count steps'' at conversation_view.dart:683; this run wants an edits-flavoured label.
- Expanded: every individual edit, each with its full report — bundle, do NOT drop or summarise away any information. All per-edit detail must remain reachable on expand.
Notes:
- Reuse the existing ClideHolderCard / folding machinery rather than building a new card.
- Grouping breaks when the file_path changes or a non-edit step interleaves (consecutive-same-file only), matching the ''subsequent edits to the same file'' wording.
- Parity with the existing meta/activity folding (T-230) — same collapse/expand affordance, just an edits-labelled run.
WORKED EXAMPLE (from user screenshot, clide_markdown.dart run): the current stream renders 11 stacked cards — 3x "Edit clide_markdown.dart", then a folded "Read … 2 steps" holder, then 7x "Edit clide_markdown.dart". With this feature it collapses to THREE cards:
- [3 edits] (the first edit run)
- [2 steps] (the existing Read holder — unchanged; this is what splits the edit run)
- [7 edits] (the second edit run)
Confirms the split rule: an interleaving non-edit step (here the folded Read run) breaks the consecutive-same-file edit grouping into two separate edit cards. Same-file edits with nothing between them collapse into one "# edits" card.
STATUS INDICATOR (live tick reuse): the bundled edits card carries ONE header status indicator that reuses the existing per-step success tick, driven by the latest edit''s state:
- edit in flight -> spinner
- edit completed -> success check (the current green tick)
- next edit starts -> back to spinner
- ...repeat, settling on check when the final edit in the run completes (or the error glyph if one fails).
So the collapsed card''s indicator animates spinner<->check as the run grows, rather than showing a static tick. Each individual edit keeps its own tick in the expanded list (unchanged); this is the aggregate indicator on the holder header/ticker. Mirror the same treatment for the existing "# steps" activity card if it doesn''t already do this.
DONE 2026-06-09 (commit). coalesceEditRuns folds consecutive same-file edits into one ''# edits'' ClideHolderCard; aggregate live status via new ClideStatusIndicator (running/success/error) + ClideSpinner (logo-mark, 3D Y rotation, reduced-motion aware), shared with the activity card. Per user: spinner is a self-contained component (not built on ConversationCard''s mark) with an AnimatedSwitcher seam for a richer spinner→check transition later. Tests: activity_cluster_test (coalesce), conversation_view_test (edits card), clide_status_indicator_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 15:43:58', '2026-06-09 16:34:46', NULL, 'ed946676507cc9223eaefad054fb9def', 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 ('06FB0TNQM7K1FN7KD8S5ZVX2VM', 'task', NULL, 'Automate supply-chain checks in CI (osv-scanner + native SHA verification)', 'Salvaged from gemini-report.md (external code-analysis). Today supply-chain checks are MANUAL — Makefile ''security'' target just prints ''Dart advisories reviewed manually before pubspec.yaml bumps''; there is no automated vuln scan or native-artifact integrity check. Automate both to harden the chain as we move to automated CI, complementing the existing prefer-zero-deps / exact-pin / licenses.yaml discipline (D-31, D-61, D-63).
1. Automate Dart dependency vulnerability scanning:
- Run an automated scanner (e.g. Google osv-scanner) against pubspec.lock on every push/PR.
- Wire into the CI workflow and/or the Makefile ''security'' target so it runs in push-check.
- Fail the gate on known advisories; keep it quiet/zero-noise otherwise.
2. Automate native/vendored dependency SHA256 verification (D-63):
- CI step / pre-push script that parses assets/licenses.yaml (+ relevant BUILD.md) for declared native artifacts (dugite-native, libtree-sitter.so), and verifies the SHA256 of the vendored binaries against the committed/expected hashes.
- Guards against silent corruption or tampering of vendored binaries; ensures they match the audited sources.
Notes:
- Both should be low-noise, runnable locally and in CI (candidate home: ci/security.sh + a Makefile target, surfaced via push-check).
- Scope is automation only — the manual review discipline already exists; this makes it enforced rather than convention.
- Source report (gemini-report.md) is being removed from the repo once this ticket captures its only actionable content.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 16:42:47', '2026-06-09 16:42:47', NULL, '5eed19fc0a5b2671003c00dedbb2f3ea', 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 ('06FB0TNQM53HQD6YV754GKBA60', 'story', NULL, 'image paste in Claude panel prompt bar', 'Support pasting images (Ctrl+V / right-click paste) into the Claude panel text input bar. The image should be sent as part of the message context to Claude, matching Claude Code CLI multimodal input.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:25:26', '2026-06-09 16:56:55', NULL, '3655929898ec949a983a88ba748d9f01', 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 ('06FB0TNQM62FKQQD0B9B80PFY4', 'task', NULL, 'Team sidebar: account/team token budget (blocked on upstream)', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''.
2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path).', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-09 17:16:34', NULL, '1a58f3b9021325310bc448e5a755f4f6', 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 ('06FB0TNQM7D5DMYF5X92Q55CBW', 'task', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Persist + expose the activity-card fold level (T-230 follow-up)', 'Follow-up to T-230. The activity-card fold level is implemented as a switchable ConversationView.foldLevel parameter (FoldLevel none/tools/thinking/everything; groupConversation re-groups per level, unit-tested), but the live Claude pane currently uses the L1 default with no way for the user to change it. Finish acceptance #4: (1) read the fold level from a persisted user setting (via ctx.settings / the kernel settings service — see how other builtins read settings), defaulting to L1; (2) add a control to change it (e.g. a command ''claude.activity.fold-level'' cycling none->L1->L2->L3, and/or an entry in the Claude/settings UI); (3) thread it into the ConversationView built in claude_pane.dart (and team_panel_host.dart). The grouping + card + a11y are already done in activity_cluster.dart + conversation_view.dart.
DONE 2026-06-09. Persisted app-scoped setting kActivityFoldLevelKey (app.claude.activityFoldLevel); command claude.activity.fold-level cycles none→tools→thinking→everything (foldLevelFromName/nextFoldLevel in activity_cluster.dart); ClaudePane + team_panel_host read the setting and re-fold live via the settings notifier. Tests: foldLevelFromName/nextFoldLevel in activity_cluster_test.', 'done', 'low', NULL, NULL, NULL, '2026-06-03 15:17:29', '2026-06-09 17:16:34', NULL, 'eccaa5f62d3b6f50eb984a4b3d7bc169', 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 ('06FB0TNQM7W0NPAEG3KWNFY9DR', 'epic', NULL, 'Native Claude integration: transcript-rendered conversation + tmux agent-team panels', 'Replace the terminal-based Claude pane with native rendering driven by Claude Code''s transcript JSONL (with cross-widget text selection), and surface experimental tmux agent teams (lead + teammates) as tiled native panels plus a team-meta sidebar. The terminal is retained only as a general IDE tool.
Why:
1. No cross-widget text selection/copy in clide''s native widgets today (regression vs a terminal).
2. PTY/TUI rendering is the fragile, OS-variant part of the Claude pane.
3. Claude Code''s experimental tmux team mode (lead + N teammates, each a tmux pane) is the flagship capability to surface as real GUI panels, not scraped TUI.
Direction: run Claude headless in tmux; render its conversation natively from the transcript JSONL; lead panel (left, with composer) + teammate tiles (right) in a responsive auto-wrap grid (1->2->3 cols); team-meta sidebar with roster + token budget.
Principle: Claude-centric first, CLI-first (D-6) a strong second. Accept isolated, version-pinned coupling to Claude Code internal contracts where it serves the Claude integration; preserve CLI/event surfaces where sensible.
Acceptance: all child tickets done; D-record landed; Claude pane renders natively with working select+copy and no terminal; a tmux team surfaces as lead + auto-wrapping teammate tiles + meta sidebar; all fragile CC-internals parsing isolated and version-pinned.
Source: plan iridescent-tinkering-umbrella (2026-05-22), grounded in an-idea.md + read-only validation of the transcript schema this session.
STATUS CORRECTED (2026-06-09): not superseded — substantially DELIVERED. 32/34 children done; the native Claude pane (transcript rendering, select/copy, no terminal), tmux team tiles + meta sidebar, composer, and D-75 all shipped. Re-flagged from ''ready'' (misleading — no startable work) to in_progress. Remaining, both non-blocking, kept under this epic: T-158 (team/account token budget — blocked on upstream /usage exposure) and T-235 (persist activity-card fold level, T-230 follow-up). Close when those land.
CLOSED 2026-06-09: all doable work delivered (native Claude pane, transcript rendering, select/copy, no terminal, tmux team tiles + meta sidebar, composer, activity-card folding incl. the persisted fold level T-235, D-75). The one blocked item — account/team token budget — was detached (T-158) and reframed as Q-34 (how + when to surface it given upstream doesn''t expose the data); T-158 is the resolver, parked in the backlog. Nothing actionable remains under this epic.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:45:15', '2026-06-09 17:23:25', NULL, 'd4abcdfdd0f3d79570f331c7385e36e5', 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 ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-09 20:03:08', NULL, '1996bc502ccd18ba28203e362ce82bc7', 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 ('06FB0TNQM6048VEGFDNEBHFMM4', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Display in-repo file paths relative to repo root in Claude conversation', 'When file paths to files inside the current repo appear in the Claude conversation, show them relative to the repo root rather than as absolute paths.
**DESIGN OPEN — needs discussion before implementation.** The exact presentation is undecided.
**Intent**
- Paths that resolve inside the workspace (CLIDE_WORKSPACE / git repo root) should read as repo-relative (e.g. `lib/app.dart` instead of `/var/mnt/data/projects/clide/lib/app.dart`).
- Goal is readability — strip the absolute prefix that''s noise for in-repo files.
**Open questions to settle in discussion**
- Visual treatment: silently rewrite the displayed text? show relative with the absolute available on hover/tooltip? a leading marker (e.g. `./` or a repo-root glyph)?
- Scope: only linkified/recognized refs (ties to T-300), or any path-looking token in the rendered output?
- Out-of-repo / absolute paths: leave untouched, or abbreviate (e.g. `~`)?
- Interaction with copy: does copying yield the relative or the original absolute path?
- Does this happen at render time only, or is the underlying text also normalized?
**Related**
- Pairs with T-300 (clickable file references) — same conversation/markdown render path; likely share path-detection + workspace-root resolution.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:04:29', '2026-06-09 20:04:54', NULL, 'a5da33062dd9373142d5292a79de8d64', 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 ('06FB0TNQM4AE2GEF6BXSGKRMGC', 'task', NULL, 'Quiet test reporter: use failures-only in ci scripts (only failures + final count)', 'VERIFIED DONE 2026-06-09: failures-only is the default reporter across all gate scripts — ci/test.sh (REPORTER var, used in every flutter/dart test invocation), ci/test_core.sh, ci/test_a11y.sh — each with a TEST_REPORTER=expanded escape hatch for debugging. No work remaining.', 'done', 'medium', NULL, NULL, NULL, '2026-06-05 10:17:47', '2026-06-09 20:07:30', NULL, '05823664d6d7b3cefbbc3f6c86c199b0', 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 ('06FB0TNQM42VPM50VKAWXBCAM8', 'story', NULL, 'Make links in the Claude conversation panel clickable to open in OS URL handler', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener.
## Behaviour
- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler.
- Hover affordance (cursor change / underline) so it reads as clickable.
- Keep the existing colorization.
## Notes
- Honour user/Claude parity (D-6) where relevant.
- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree.
- Guard against non-http schemes / malformed URLs.
DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:53', '2026-06-09 20:17:04', NULL, '4b6b10e6e323af31ce0e87055cf738d5', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-09 20:25:49', NULL, 'cebb365e52ecabe1de21f958aae69a9a', 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 ('06FB0TNQM5QJC039TMDF5JGMXC', 'task', NULL, 'Number-key shortcuts for interactive prompt buttons (CLI-style 1/2/3)', 'Muscle-memory gap from the Claude Code CLI: in clide''s interaction-zone prompts (D-78), the user reaches for number keys 1/2/3 to pick a button but has to click. Map number keys (1..N) to the visible buttons/options, and PREFIX each button label with its number for discoverability (CLI shows ''1. Allow / 2. ... / 3. No'').\n\nSurface: ToolPromptCard in lib/builtin/claude/src/prompt_card.dart. Two modes:\n- PERMISSION (_permission, ~line 119): buttons Allow (primary) / ''Allow & don''t ask again'' (only when permissionSuggestions present) / Deny. Map 1=Allow, 2=''Allow & don''t ask again'' (when shown) else Deny, 3=Deny when the middle one is shown. Match the CLI''s ordering/numbering.\n- ASKUSERQUESTION (_question / _optButton, ~line 280/294): each option per question gets a number (1..N) that selects (single) or toggles (multiSelect) it; keep Enter for Submit/Next and Esc/back as-is. Prefix option labels with the number alongside the existing radio/checkbox glyph (the ''○/●'' in _optButton).\n\nKEY CAVEAT (do this right): the prompt card hosts a free-text note field (_NoteField) and AskUserQuestion ''Other'' free-text. Number keys MUST NOT be captured while focus is in a text field (otherwise typing ''1'' triggers a button). Gate the shortcut on focus not being in an editable, i.e. intercept at the prompt''s Focus/FocusScope and bail when a text field has focus — same consumer-interception discipline as D-82 (Vim) / the editor. Keep the ClideButtons (clicks + AT) intact; the number key is an additional accelerator. Accessibility: include the number in the button''s semantics label.\n\nAcceptance: with a permission prompt or AskUserQuestion open and focus not in a text field, pressing 1/2/3 (and up to N for question options) activates the matching button/option; labels are number-prefixed; typing in a note/Other field is unaffected; mouse + screen-reader paths still work.
Refinement (2026-06-05, user): match the Claude Code CLI''s actual behavior for the text-field interaction. The number key triggers the button/option shortcut when it would be the FIRST character typed -- i.e. focus is NOT in a text field, OR focus IS in the note/Other field but that field is currently EMPTY. Once the field has any content, digits type normally (no shortcut). This supersedes the earlier ''never capture digits while a text field has focus'' gate: it''s better because focus often defaults into the (empty) note field, so the shortcut still fires there (muscle-memory case) while a digit mid-note still types. Implementation: intercept the digit at the prompt focus scope; consume+activate only when the focused editable (if any) is empty, else let it through to type. The CLI exhibits this exact ''footgun'' (digit-as-first-char in an empty field acts as the choice) and we intentionally mirror it.
DONE 2026-06-09. ToolPromptCard: number keys pick the matching button/option (labels prefixed 1./2./3.), Enter confirms the primary action. Permission 1=Allow / 2=Allow&remember(if offered) else Deny / 3=Deny; AskUserQuestion 1..N select+toggle options + Other. Card autofocuses; _onKey self-guards on hasPrimaryFocus so a focused note field types digits normally. Permission/option actions shared between buttons + keys. Tests: prompt_card_test number-key group + updated label finders.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-05 09:38:46', '2026-06-09 20:27:17', NULL, '18039168ae44b4bd32d516ada0da1a46', 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 ('06FB0TNQM531VE0C6N36AT12SR', 'task', NULL, 'Audit widget tests for ultrawide coverage (IDE runs wide)', 'Lesson from T-239: a width-PROPORTIONAL layout bug (status-bar right group drifting to mid-bar via a Spacer-vs-flex split) was invisible at the default 800px flutter_test surface and at 1200px, but glaring at 3440px. clide is an IDE — ultrawide (3440, 5120, ultrawide+vertical splits) is a VERY common real screen size, so width-sensitive layout that only ever gets tested at 800px hides exactly this class of bug.\n\nScope: review the widget/golden suite and add ultrawide coverage where layout is width-sensitive. Candidates: the bottom status bar (done — test/app_statusbar_test.dart now covers 600 + 3440), panel/slot layout + drag-resize (lib/app.dart RootLayout, SlotHost), tab strips (overflow/scroll at wide), ClideMarquee (T-160 — only tested narrow; also check it doesn''t mis-behave wide), conversation view / activity card, the command palette + quick-open overlays (max-width/centering on wide), modal pickers, status items.\n\nApproach: (1) add a shared test helper to pump at a given surface width via tester.view.physicalSize (note: a wide SizedBox under the default 800px surface is CLAMPED — must set view.physicalSize, see test/app_statusbar_test.dart pumpAt). (2) For layout-sensitive widgets, assert key positions/no-overflow at BOTH a normal and an ultrawide width. (3) Don''t blanket-add to every test — target width-sensitive layout (Row/Spacer/Expanded/Flexible/Align, max-width caps, centering). Note any widget that SHOULD cap/center on ultrawide (readability) vs fill. Relates to Q-26 (small-screen layout) — same responsive concern at the other end.
DONE 2026-06-09. Added the reusable setSurfaceSize(tester, width) helper to test/helpers/widget_harness.dart (the foundation the ticket asked for). Ultrawide (3440) cases added on the flagged width-sensitive surfaces: ClideMarquee (was narrow-only — now verified static when a line fits a wide slot) and the quick-open palette (verified width-capped, not stretched). Status bar already covers 600+3440 (T-239). AUDIT NOTES on the rest: overlays/pickers use fixed-width panels (ClideMenu/quick-open 480, anchored popovers) so they''re inherently capped/centered; tab strips (MultitabPane) and status items overflow only when NARROW (a small-screen concern, Q-26), not wide; RootLayout slots are explicit fixed/flex widths with drag-resize. The helper is now in place for any future width-sensitive widget. Not exhaustive by design (audit + reusable tooling + the high-risk surfaces).', 'ready', 'medium', NULL, NULL, 'D-23', '2026-06-05 10:02:31', '2026-06-09 20:34:30', NULL, 'cad1d9909d676a7395a46320e9702560', 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 ('06FB0TNQM4K1MTP2MHRKKNXBFM', 'task', NULL, 'macOS: deliver clide:// deep links via AppDelegate openURLs', 'Follow-up to T-56. The clide:// scheme is registered in macos/Runner/Info.plist (CFBundleURLTypes) and the parser+routing are done (parseArgv → editor.open → IPC). But macOS does NOT pass the URL as argv like Linux does (Exec %U); it delivers via the AppDelegate. Wire AppDelegate.application(_:open:) (or applicationDidFinishLaunching for a cold-start URL) to forward the clide:// URL string to Dart over a method channel (mirror the existing clide/window + clide/clipboard channels), and on the Dart side run it through parseArgv and dispatch to the local IPC (same path the CLI uses). Needs a real macOS machine to verify — deliberately not shipped blind in T-56. Refs: macos/Runner/AppDelegate.swift, lib/src/cli/argv_to_request.dart (_deepLinkToRequest).', 'backlog', 'low', NULL, NULL, NULL, '2026-06-09 20:41:22', '2026-06-09 20:41:22', NULL, 'c1943138c8e1f3dedd400bc525400bf2', 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 ('06FB0TNQM4BRWWPB4R06Q31XV0', 'story', NULL, 'deep link protocol handler', 'Register clide:// protocol handler. Support clide://open?path=/repo/file.md&line=42 for external tools to open files at specific locations. Useful for CI links, error reports, and cross-tool integration.
DONE (Linux) 2026-06-09. clide://open?path=&line= → editor.open via parseArgv, routed through the existing CLI→IPC path into the running window (single-instance for free). Parser validates action/path/line; tests in argv_to_request_test. Scheme registered: linux/clide.desktop (x-scheme-handler/clide, Exec already %U) + macOS Info.plist CFBundleURLTypes. Linux works end-to-end. macOS URL DELIVERY (AppDelegate openURLs → method channel → Dart) deferred to T-303 — needs a macOS machine to verify, not shipped blind.
SECURITY REDESIGN 2026-06-09 (D-90): per user, the clide:// surface is now paranoid. The link no longer translates to a command in parseArgv — it routes the raw URL to a new builtin.deeplink handler gated by a DEFAULT-DENY allowlist (kDeepLinkSafeActions = {open} only — run/git/write/passthrough rejected) AND a mandatory confirmation prompt (''an external link wants to: … allow?'') before any action. The generic CLI-passthrough idea is deliberately NOT shipped (it would make a webpage a remote control); each new action is an explicit allowlist + handler addition. Tests: deep_link_test (allowlist boundary), extension_test (gating).', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-09 20:57:53', NULL, '26038fc931f2848f7327905c101de662', 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 ('06FB0TNQM6SK3D1QYACBPHHBZ0', 'bug', NULL, 'GLib-GIO-CRITICAL: GFileInfo created without standard::size on startup (Linux)', 'On Linux desktop startup, two GLib-GIO-CRITICAL warnings fire to stderr:
GLib-GIO-CRITICAL: GFileInfo created without standard::size
file ../gio/gfileinfo.c: line 1865 (g_file_info_get_size): should not be reached
Repro: launch the app (make run / flutter run) on Linux. The pair fires early in boot — right after the primary pane binds its session and before the IPC server starts listening (observed ~0.3s apart, e.g. 15:53:15.570 and 15:53:15.832), on every launch.
Cause: some code path calls g_file_info_get_size() on a GFileInfo that was created/queried WITHOUT requesting the G_FILE_ATTRIBUTE_STANDARD_SIZE (''standard::size'') attribute. A grep of clide''s own code (lib/, linux/runner/, native/) finds no direct g_file_info / g_file_query_info usage, so it is most likely inside GTK/GLib itself or a Flutter Linux plugin''s file enumeration (icon/thumbnail/mime probe, path lookups), not clide Dart/C++. The GTK file-chooser in linux/runner/clide_app.cc is on-demand only, so it is not the trigger (the warnings fire at boot).
Impact: low — console noise at CRITICAL level; no observed functional breakage. But a size query that ''should not be reached'' may be reading a bogus/zero size somewhere worth confirming.
Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream.
Env: Fedora, GTK Linux embedder, flutter run.
UPDATE (2026-06-09): also fires MID-SESSION, not only at boot — contradicts the "fires early in boot, on every launch" framing above. Observed log: app booted 16:47:01, but the GLib-GIO-CRITICAL pair fired at 18:51:07.841 / 18:51:08.190 (~2h into the session), near pane/session activity. So the trigger is more likely a file-info code path tied to a user action or background file enumeration than pure startup. Re-scope the investigation to capture the stack when it fires mid-session (G_DEBUG=fatal-warnings / gdb break on g_log) rather than only at boot.
RESOLVED (2026-06-09).
Root cause CONFIRMED by reproducer: the Open Workspace folder picker. `gtk_file_chooser_dialog_new(..., SELECT_FOLDER, ...)` at linux/runner/clide_app.cc:222 builds a GtkPlacesSidebar that enumerates bookmarks/recent and internally creates GFileInfo objects WITHOUT G_FILE_ATTRIBUTE_STANDARD_SIZE, then calls g_file_info_get_size() on them — GTK''s own bug. A standalone repro (gtk3, SELECT_FOLDER dialog, auto-cancel) emitted the exact warning pair ~0.3s apart, matching the user''s mid-session log (18:51:07.841 / 18:51:08.190). The original "fires at boot" framing was wrong; the picker is opened on demand. Ruled OUT the T-138 clipboard channel: gtk_clipboard_wait_for_uris/wait_for_image emit nothing for text/image/file-uri clipboard states under G_DEBUG=fatal-warnings.
No public GTK API exists to fix at source (the sidebar''s enumeration is private; can''t inject the missing attribute).
Fix (both, per user direction):
1. Switched pickDirectory to GtkFileChooserNative — portal-backed, runs out-of-process in sandboxed/Flatpak builds so the noise never enters our process; nicer Wayland picker. (Verified: outside a sandbox GtkFileChooserNative falls back to the in-process chooser and STILL warns on this box — portal is only used under Flatpak/snap or GTK_USE_PORTAL=1.)
2. Added clide_gio_log_filter in clide_app.cc, installed via g_log_set_handler("GLib-GIO", CRITICAL, ...) in clide_app_startup. Drops ONLY messages containing "g_file_info_get_size" or "without standard::size"; forwards every other GLib-GIO log to the default handler. This is the universal fix (works in every run mode).
Verification: standalone reproducer with both the native chooser AND the filter — size warnings gone, an unrelated GLib-GIO CRITICAL still passes through (filter is scoped, not a blanket mute). `make build-linux` green. CHANGELOG updated under Fixed.', 'done', 'low', NULL, NULL, NULL, '2026-06-08 17:12:49', '2026-06-09 21:21:08', NULL, 'bb5703a93c41803651ecd4c1210b23bb', 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 ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-09 21:22:44', NULL, 'c29f22db34a367ee5eb7234a75e75616', 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 ('06FB0TNQM50S6WRD81V3Z0NH1M', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Bottom input box is misaligned when the status bar is hidden', 'The bottom input/prompt box (the composer, shown with the edit-pencil affordance on the right) is aligned weird when the status bar is not visible.
Symptom: with the status bar hidden, the input box''s alignment/padding looks off (see screenshot — single-line box spanning full width, content not sitting where it should relative to the window bottom).
Likely cause: the composer''s vertical alignment / bottom padding assumes the status bar is present below it and reserves/relies on that space. When the status bar is hidden, that assumption breaks and the box no longer sits flush/centered correctly.
Expected: the input box sits correctly flush to the window bottom (or with consistent padding) regardless of whether the status bar is visible. Layout should not depend on the status bar being present.
Notes:
- Audit the bottom-of-window layout stack (composer / interaction zone + status bar). The composer should bottom-anchor independently of the status bar''s visibility.
- Related to the bottom interaction zone work (D-78) and may interact with T-297 (scroll on input resize). Also relevant once the status-bar collapse toggles (T-294) can change what''s in the bar.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 16:06:41', '2026-06-09 21:36:05', NULL, 'a82415278c5d2653d6e7092a0fbbb6e8', 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 ('06FB0TNQM5YNEQM1WY25BV7TD8', 'bug', NULL, 'app_test ''Open Folder non-repo'' hangs 10min — runAsync/ReceivePort teardown wedge', 'Pre-existing (reproduces at base commit 3a78dfa, predates the T-267 conversation-rendering epic). The widget test `test/app_test.dart` "Open Folder on a non-repo path surfaces the ''no git repo'' dialog" times out after 10 minutes; teardown is wedged on `_RawReceivePort._handleMessage`.
Bisected (each a 45s-timeout repro, all on this box):
- A bare `Process.run` inside `tester.runAsync` (no app, no extensions) hangs → `Process.run`-in-`runAsync` leaks its exit ReceivePort here.
- `pumpApp` + empty `runAsync`, and `pumpApp` + a 1.5s real delay → both PASS (boot + runAsync alone is fine).
- The full openFolder tap flow hangs even when project validation is stubbed to a synchronous, pure-Dart `.git` walk (no subprocess) AND `runAsync` is removed — so the wedge is not solely the git subprocess; something in the booted-app + extensions + open-folder command path holds a native port that teardown waits on forever.
Quarantined with `skip:` so the suite/gate stays green. Real fix: find the leaked native async resource (likely a Process/Isolate/FakeDaemonClient port reachable from the open-folder command or app boot under the test harness) and ensure it''s drained/cancelled before teardown — or drive the "no git repo" assertion without booting the resource. Then remove the skip.', 'done', 'high', NULL, NULL, NULL, '2026-06-08 11:29:22', '2026-06-09 21:42:59', NULL, 'c49e75c2d73ddec10469bf2405dde626', 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 ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Group/tool collapser cards (ClideHolderCard vs ConversationCard) handle collapse + visuals inconsistently; extract one shared collapser primitive with a color property, fixed-width counter slot, and the status icon hard against the card edge.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:15:39', NULL, '0c5af482db33b4ea4dd241bd3491a51f', 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 ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Group/tool collapser cards (ClideHolderCard vs ConversationCard) handle collapse + visuals inconsistently; extract one shared collapser primitive with a color property, fixed-width counter slot, and the status icon hard against the card edge.
## Bug / discrepancy
There are two collapser-card families with inconsistent collapse handling and
"card within card" chrome:
- **`ClideHolderCard`** (lib/builtin/claude/src/holder_card.dart, T-266) — the
group container for Activity (`_ActivityCard`) and Edits (`_EditRunCard`).
Collapse = ticker row + frame background + a header caret. Header order:
`[chevron] summary … [status] [count]`. Status indicator sits INBOARD, left
of the count label. Border/text hardcoded to `panelBorder` / `globalTextMuted`
(no color knob).
- **`ConversationCard`** (lib/builtin/claude/src/conversation_card.dart, T-262)
— the per-tool merged card. Collapse = a single leading caret only (no
background/ticker). Header order: `[caret] label summary/spacer [status]
[actions]`. Has `borderColor` + `accent`.
So the toggle is attached differently, the status mark sits in a different
place, the counter has no fixed slot, and color customization exists on one but
not the other. They should grab the SAME widget (each its own instance — NOT
collapsing across types).
## Proposal — one `ClideCollapserCard` primitive
Both families render through a single collapser primitive. Requirements:
1. **`color` property** drives the border AND the label/text color, so each
instance keeps its visual identity (activity = muted, edits = accent,
error = red, sub-agent, …) through one widget. Default = panelBorder/muted.
2. **Fixed-width counter slot** — the `N steps` / `N edits` count lives in a
right-aligned fixed-width slot so it (and the trailing status icon) never
shift as the number grows or the status appears/changes.
3. **Status icon hard against the card edge** — flip the current
`[count][status]` to `[status]` against the right edge with the counter
inboard of it. The chevron already hugs the LEFT edge. Rule: both icons hug
the card edges; text sits inboard.
4. **Consistent collapse/expand attachment** across both uses (keep the
group-container background-tap-to-collapse for the tail-follow case, D-78,
but the visible control + header layout are identical).
5. Status glyphs: `ClideRunStatus` running (logo-mark spinner ◐) / success ✓ /
error ✕, reusing ClideStatusIndicator.
Nested sub-cards render the SAME primitive (card-within-card consistency).
## Scope
- New `ClideCollapserCard` in lib/widgets/src/ (exported from widgets.dart).
- Migrate `ClideHolderCard` (Activity/Edits) and `ConversationCard` (merged
tool card) onto it; keep per-type content/labels.
- Hold visual fidelity via goldens (conversation_card_goldens_test,
ClideHolderCard golden) + a11y (expanded/collapsed semantics, focusable
toggle).
## Wireframe
docs/design/wireframes/cards/collapser-card.json (+ .png) — collapsed (3 color
variants), expanded with nested sub-cards, annotated for the counter slot +
edge-anchored icons.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:16:05', NULL, '384d36f5718f573bdaba210b0b8a147f', 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 ('06FB16YM1Y910T07Y1Y22MGTEM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Card the ''context'' conversation block like the rest of the cards', 'The injected "context" block (skill-load / command-expansion / system-reminder messages) renders as a frameless `ConversationCardVariant.bare` card in `lib/builtin/claude/src/conversation_view.dart:410-419`, so it reads as a bare `> context …` row wedged between the fully-framed Skill and Bash tool cards (see pasted screenshot). It looks unfinished next to the carded tool calls.
Make the context block follow the standard collapser-card pattern established for all group/tool cards in the `collapser-card` wireframe (docs/design/wireframes/cards/): a proper card frame with the chevron toggle hard against the left edge, label, and the fixed-width right-aligned counter/status slot — so it is visually consistent with the Skill/Bash/Activity cards around it.
Keep the de-emphasis that D-78 calls for (muted accent, collapsed-by-default, first-line summary) — this is about giving it the same *frame* as the other cards, not the blue "you" accent. The same treatment likely applies to the sibling bare cards that share this branch (agent prompt, thinking) for consistency; scope to context first and note whether thinking/agent-prompt should follow.
Acceptance: the context row sits in a framed card matching the collapser-card wireframe geometry, still muted and collapsed by default, with the toggle and counter/status slot aligned to the other cards. Update/extend the relevant golden(s).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:42', '2026-06-10 08:17:42', NULL, 'b7f1ad221558368a6c547e00043087b7', 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 ('06FB170DJA02EH2E8HMH7X7SS4', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'frame0-wireframe skill: pause for user approval after showing each screen', 'The `frame0-wireframe` skill (.claude/skills/frame0-wireframe/SKILL.md) currently runs its Core Workflow straight through — write JSON → push → export → show — and tends to keep generating additional wireframes/screens on its own without stopping. The point of the skill is *dialogue*: each shown screen is a checkpoint for the user to react to, not busywork to batch through.
Adjust the skill so that after step 5 (showing an exported screen inline), it STOPS and waits for explicit user approval/feedback before authoring or exporting the next screen. One screen at a time: show it, ask, then proceed only on a go-ahead. Do not fan out a whole set of wireframes unprompted.
Acceptance: SKILL.md instructs the agent to surface one screen and pause for user approval before continuing; the loop is approval-gated, framing wireframing as a back-and-forth design conversation rather than a one-shot generation run.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:57', '2026-06-10 08:17:57', NULL, 'e9e59e2c52e643e2c0a2831c90d8cda8', 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 ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing — constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget — each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** — carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** — a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** — the unified collapser. Covers edits, bash, task updates, runs — every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) — the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers — single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser — they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:43:03', NULL, '444e8c535d796bae0519d4a4761b12e1', 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 ('06FB0TNQM5E41Q8TX8X55X4MZ0', 'story', '06FB0TNQM5GSR5WPC2T7CJM8XW', 'editor respects .editorconfig settings', 'When a file is opened in the editor, read .editorconfig from the workspace root and apply: indent_style, indent_size, max_line_length (ruler/wrap guide), end_of_line, trim_trailing_whitespace, insert_final_newline. Parse the INI format ourselves (small, no dep). Glob matching per the EditorConfig spec.
DONE: The editor honours .editorconfig end-to-end. New source-agnostic EditorSettings model (editor_settings.dart) + composition seam (editor_settings_resolver.dart); .editorconfig demoted to a source (editorconfig.dart, own INI parser + glob matcher, root/nearest-wins precedence, no deps). Registry resolves on open and re-resolves open buffers when a .editorconfig is saved in-app (editor.settings-changed; a save hook, not an fs-watch). Editor: Tab/Shift+Tab indent + max_line_length ruler; save applies end_of_line/trim_trailing_whitespace/insert_final_newline. Follow-ups: T-290 (edit from settings panel), T-291 (external fs-watch). 100% coverage on new model/resolver/parser.', 'done', 'medium', NULL, NULL, NULL, '2026-04-22 20:17:26', '2026-06-10 08:57:51', NULL, '4c52cb612afcab62f743c0beae221cbf', 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 ('06FB0TNQM42VPM50VKAWXBCAM8', 'story', NULL, 'Make links in the Claude conversation panel clickable to open in OS URL handler', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener.
## Behaviour
- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler.
- Hover affordance (cursor change / underline) so it reads as clickable.
- Keep the existing colorization.
## Notes
- Honour user/Claude parity (D-6) where relevant.
- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree.
- Guard against non-http schemes / malformed URLs.
DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:53', '2026-06-10 08:57:51', NULL, '599030f7849f46625ecba398407f186d', 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 ('06FB0TNQM531VE0C6N36AT12SR', 'task', NULL, 'Audit widget tests for ultrawide coverage (IDE runs wide)', 'Lesson from T-239: a width-PROPORTIONAL layout bug (status-bar right group drifting to mid-bar via a Spacer-vs-flex split) was invisible at the default 800px flutter_test surface and at 1200px, but glaring at 3440px. clide is an IDE — ultrawide (3440, 5120, ultrawide+vertical splits) is a VERY common real screen size, so width-sensitive layout that only ever gets tested at 800px hides exactly this class of bug.\n\nScope: review the widget/golden suite and add ultrawide coverage where layout is width-sensitive. Candidates: the bottom status bar (done — test/app_statusbar_test.dart now covers 600 + 3440), panel/slot layout + drag-resize (lib/app.dart RootLayout, SlotHost), tab strips (overflow/scroll at wide), ClideMarquee (T-160 — only tested narrow; also check it doesn''t mis-behave wide), conversation view / activity card, the command palette + quick-open overlays (max-width/centering on wide), modal pickers, status items.\n\nApproach: (1) add a shared test helper to pump at a given surface width via tester.view.physicalSize (note: a wide SizedBox under the default 800px surface is CLAMPED — must set view.physicalSize, see test/app_statusbar_test.dart pumpAt). (2) For layout-sensitive widgets, assert key positions/no-overflow at BOTH a normal and an ultrawide width. (3) Don''t blanket-add to every test — target width-sensitive layout (Row/Spacer/Expanded/Flexible/Align, max-width caps, centering). Note any widget that SHOULD cap/center on ultrawide (readability) vs fill. Relates to Q-26 (small-screen layout) — same responsive concern at the other end.
DONE 2026-06-09. Added the reusable setSurfaceSize(tester, width) helper to test/helpers/widget_harness.dart (the foundation the ticket asked for). Ultrawide (3440) cases added on the flagged width-sensitive surfaces: ClideMarquee (was narrow-only — now verified static when a line fits a wide slot) and the quick-open palette (verified width-capped, not stretched). Status bar already covers 600+3440 (T-239). AUDIT NOTES on the rest: overlays/pickers use fixed-width panels (ClideMenu/quick-open 480, anchored popovers) so they''re inherently capped/centered; tab strips (MultitabPane) and status items overflow only when NARROW (a small-screen concern, Q-26), not wide; RootLayout slots are explicit fixed/flex widths with drag-resize. The helper is now in place for any future width-sensitive widget. Not exhaustive by design (audit + reusable tooling + the high-risk surfaces).', 'done', 'medium', NULL, NULL, 'D-23', '2026-06-05 10:02:31', '2026-06-10 08:57:51', NULL, '8e987583d005fafba43bb452cadf1dbd', 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 ('06FB0TNQM4GN07FN8GAXZ20YAR', 'story', '06FB0TNQM7W0NPAEG3KWNFY9DR', 'Render pasted-image @path tokens as thumbnails in the message log', 'When a user message contains a pasted-screenshot reference — an @<path> token pointing at an image (the composer''s ComposerAttachment.pathToken format, e.g. @/home/<user>/.cache/clide/pasted/paste-<ts>.png, see screenshot) — the conversation log renders it as the raw path string. Show it as an inline THUMBNAIL instead.
The composer already renders pre-send attachments as Image.file thumbnail chips (clipboard_paste.dart + claude_composer _chip); this carries that into the post-send message log (UserMessage rendering in conversation_view/conversation_card).
Scope:
- When rendering UserMessage text, detect @<path> tokens; for image paths (reuse the extension check behind _looksLikeImage: .png/.jpg/.jpeg/.gif/.webp/.bmp), render a bounded Image.file thumbnail in place of the bare token. Surrounding prose still renders normally; a message can carry multiple tokens.
- Click/activate the thumbnail to open the full-size image (a preview dialog). Keyboard-operable + a11y label (filename).
- Graceful fallback: a missing/unreadable file (pasted temp files can be cleaned up) degrades to a small placeholder or the path text via Image.file errorBuilder — never a crash/exception.
- Non-image @path tokens MAY render as a file chip (filename + icon) like the composer, but image thumbnails are the focus.
- Note: Image.file reads the path directly via dart:io, so this is NOT gated by the files.read allow-list (D-80) — the cache dir is outside the workspace and that''s fine for display.
- Display-only: the text actually sent to Claude is unchanged; this only affects rendering.
Acceptance:
1. A user message with an @<path> image token shows an inline thumbnail in the log (not the raw path).
2. Clicking/activating the thumbnail opens the full image.
3. A missing/unreadable referenced file degrades to a placeholder (or the path text), no exception.
4. Prose and any non-image @path tokens around it still render readably; multiple tokens in one message all resolve.
5. The message content delivered to Claude is unchanged (render-only). Relates to T-142 (paste attachments).
MERGED T-254 (duplicate) into this ticket. Extra nuance carried over from T-254: keep the file path available (e.g. as a small caption/subtitle under the thumbnail, or via the lightbox) so it can still be copied/referenced. Reuse the existing inline image card + lightbox (T-252) rather than a new component.
RETRACTED the earlier ''merged T-254'' note — T-236 and T-254 are distinct tickets (per user); disregard that note. T-236 stays scoped to its own description.
DONE 2026-06-09 — implemented as the hybrid (D-89): inline thumbnail in the message prose that opens the lightbox on click/Enter. New ImageThumbnail + openImageLightbox (lib/builtin/claude/src/image_thumbnail.dart); ClideMarkdown gained an onImageToken WidgetSpan seam. Missing file → placeholder; multiple tokens; non-image @path stays text; render-only (copyText unchanged). Composer chips reuse the same 44px thumbnail. Tests: image_thumbnail_test + conversation_view_test cases. Closes alongside T-254.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 15:31:01', '2026-06-10 08:57:51', NULL, '8eb9013e34cd82b993d06b6b15eacf40', 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 ('06FB0TNQM4BD8PXDRHBF001200', 'bug', NULL, 'Flaky: pty session_test ''write sends keystrokes to child'' starves under parallel/coverage load', 'test/pty/session_test.dart:47 ''write sends keystrokes to child'' (and its sibling pty integration tests) intermittently fail when run in a large parallel pool — observed twice during T-29: once in a bare ''flutter test --coverage'' (whole suite, max parallelism + coverage instrumentation) and once mid pre-push gate (''+570 -1''). Passes reliably in isolation and via the gate''s SERIAL test-core pass (''dart test test/pty'', +571 ok), so it is not a logic bug.
Root cause: these tests spawn a real /bin/sh via NativePty and wait on the reader ISOLATE to deliver bytes. Under heavy CPU contention the reader isolate is starved and the 20s ioTimeout (test/helpers/timeouts.dart) elapses before the shell''s first byte / echo response is delivered. Already hardened once (T-108 swapped wall-clock sleeps for event-driven waits; first-byte Completer + 25ms-poll _waitForBuffer), but the isolate-scheduling assumption still breaks under load.
These tests are tagged ''pty'' and are meant to run in the gate''s serial pass. Fix options: (a) ensure the parallel coverage pass EXCLUDES tag:pty (run pty only in the serial pass) so they never compete for isolate scheduling — verify ci/test.sh / dart_test.yaml tag routing; (b) harden against isolate starvation (e.g. give the reader isolate priority, or assert on delivery via a more robust signal); (c) document that ''flutter test --coverage'' over the whole tree is not a supported invocation (use make test + the gate). Prefer (a). Refs: T-108, T-96 (reader-isolate hangs), T-192 (gate parallel/serial passes), D-23 (test pyramid).
FIXED 2026-06-09 (commit 0231cb4). Root cause: ci/test_core.sh ran ''dart test test/ipc test/pty ... test/pql'' in the DEFAULT parallel pool, so test/pty''s real-PTY tests contended for fds+CPU with the other core suites and the reader isolate was starved. (ci/test.sh already isolated pty via --concurrency=1 --tags pty; test_core.sh was the gap.) My manual ''flutter test --coverage'' repro was a separate wrong-invocation artifact — the flutter runner can''t reliably deliver the PTY master fd, which is why the gate excludes pty from the flutter pool entirely.) Fix: test_core.sh now runs a serial ''--concurrency=1 --tags pty'' pass + a parallel ''--exclude-tags pty'' pass, mirroring ci/test.sh. Verified: pty pass +5 stable across repeated runs; full core +571 unchanged. Option (a) from the ticket.', 'done', 'medium', NULL, NULL, 'D-23', '2026-06-09 14:57:32', '2026-06-10 08:57:51', NULL, 'd6e4b6820ef300201b2bea67d965f90d', 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 ('06FB0TNQM61SRVBDRFFN2S80T4', 'story', NULL, 'Render pasted images via the image viewer card in the Claude conversation panel instead of echoing only the file path', 'When the user pastes an image, the conversation panel echoes back the file path as plain text (e.g. @/home/.../paste-<ts>.png). We now have an image viewer card — render the pasted image inline using that card instead of (or in addition to) the bare path.
## Behaviour
- Detect a pasted-image path in the conversation stream and render the image viewer card.
- Still surface the file path (e.g. as a caption / subtitle on the card) so it can be copied/referenced.
- Reuse the existing image viewer card component rather than building a new one.
## Notes
- Applies to the Claude conversation panel rendering path.
- Consider failure cases: missing/deleted file, non-image paste, very large images.
CANCELLED as a duplicate of T-236 (2026-06-09). T-236 is the concrete spec for rendering pasted @path images inline in the Claude conversation; its scope now includes T-254''s path-as-caption nuance. Implement under T-236.
RETRACTED the cancellation — NOT a duplicate of T-236 (per user, 2026-06-09). Both stay open. T-254 remains its own ticket.
DONE 2026-06-09 — satisfied by D-89 together with T-236. The lightbox-expansion + path-retention (filename a11y label / unchanged copyText) intent from T-254 is delivered by the shared ImageThumbnail → lightbox; the chosen presentation is inline (not a separate card) per the user. See T-236 / D-89 / commit.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:54', '2026-06-10 08:57:51', NULL, 'e34e357b7085fa01d97f4a33d99ca86a', 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 ('06FB0TNQM42WW0SY3XEXKE8PK8', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Chosen theme is not persisted per-repo across loads', 'Picking a theme (status-bar switcher T-234, or the Settings modal) applies live but is never saved, so every reload resets to the first bundled theme. The choice should persist PER REPO.
Root cause: ThemeController.select() (lib/kernel/src/theme/controller.dart:43) only mutates _currentName + notifies — no persistence. It is constructed in facade.dart:160 as ThemeController(bundled: bundledThemes) with no initialName, so on boot it always falls back to bundled.first.
Infra already exists — no new store needed: SettingsStore (lib/kernel/src/settings.dart) has a PROJECT scope that persists to <repo>/.clide/settings.yaml via ''project.*'' keys, and ProjectManager.openProject() already calls settings.setProjectDir() on open (project.dart:130), which loads that file.
Proposed fix:
1. PERSIST on change — wire a listener on ThemeController (in facade.dart, where both theme + settings exist) that writes the current theme to settings whenever currentName changes; use project scope (''project.theme'') when a repo is open. currentName already encodes the high-contrast variant (resolveThemeName -> ''<base>-hc''), so the HC toggle persists for free.
2. RESTORE on load — the project dir is set AFTER boot, so restoration must hook project-open (ProjectManager.openProject / settings.setProjectDir), not just construction: when a project opens, read ''project.theme'' and, if present + known, theme.select() it. Guard ArgumentError for a theme that is no longer bundled (fall back to default, don''t throw).
Open question: app-scoped fallback. Persist project-scoped per the report; optionally also write ''app.theme'' as the global default so a brand-new repo inherits the last choice instead of bundled.first. Decide during implementation.
Acceptance: pick a theme (incl. the High-contrast toggle) -> restart -> same theme restored for that repo; two repos keep independent choices; with no repo open, the app default applies; a saved theme that no longer exists degrades to the default without throwing.
Tests: SettingsStore round-trips ''project.theme''; opening a project applies the saved theme to the controller; select() persists; unknown saved theme falls back. Refs: T-234 (theme switcher), T-288/D-88 (picker), SettingsStore, ProjectManager.
FIXED 2026-06-09 (this commit). Added lib/kernel/src/theme/theme_persistence.dart + wireThemePersistence() in facade. Decision on the open question: persist BOTH project.theme (per-repo, in .clide/settings.yaml) AND app.theme (global default) — so a themed repo keeps its choice, an unthemed/new repo inherits the last global choice, and the HC variant persists (name encodes -hc). Restore prefers project over app; unknown theme is ignored (no throw). Tests: test/kernel/src/theme_persistence_test.dart (6 cases — persist app/project, HC, restore-on-open, boot restore, unknown fallback). All green.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 15:12:15', '2026-06-10 08:57:51', NULL, 'f71e40ac7f3e0a8dd2e52366b1ff5cb9', 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 ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing — constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget — each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** — carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** — a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** — the unified collapser. Covers edits, bash, task updates, runs — every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) — the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers — single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser — they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 09:13:08', NULL, 'f15f786bf4e7f70a427368d3dd5b96aa', 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 ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 09:28:04', NULL, 'aa6646cde3277aabd22519d1b35e48c0', 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 ('06FB1S7613SYF0M9XQT5JNWM40', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Lightbox: click anywhere outside the image should dismiss', 'When the image lightbox is open, clicking outside the image should close it, matching Esc and the close (x) button.
Today only the thin margin around the lightbox dismisses. The DialogRouter host (lib/kernel/src/dialog.dart:95-103) renders a backdrop that dismisses on tap, but wraps the dialog content in a tap-swallowing GestureDetector (onTap: () {}). ClideLightbox fills a 94%-of-screen SizedBox whose Positioned.fill InteractiveViewer (lib/widgets/src/clide_lightbox.dart:93-113) covers the entire box. So taps on the dark area beside a smaller-than-94% image hit the swallower, not the backdrop, and nothing happens.
Fix: make taps that land outside the actual image bounds dismiss — e.g. add an onTap to the lightbox''s outer GestureDetector that calls onDismiss when the tap is not on the image (InteractiveViewer child), rather than relying on the host backdrop only reaching the 6% margin. Preserve double-tap-to-reset, scroll-to-zoom, and pan; a single tap on empty canvas closes, a tap/drag on the image does not.
Acceptance: single click on the dimmed area surrounding the image closes the lightbox; clicking/dragging/zooming the image does not; Esc and the x still close.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 09:37:31', '2026-06-10 09:37:31', NULL, '0063d28d4e7b97a9caef8b082a0a6a71', 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 ('06FB1SJKA7KRCQMZE41SPQG780', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Prompt card: numpad digits should select options, not just the number row', 'The Claude prompt card (permission / AskUserQuestion) number-key shortcuts only respond to the top number-row digits, not the numeric keypad. Users on a full keyboard expect numpad 1-9 to pick a button/option too.
Root cause: lib/builtin/claude/src/prompt_card.dart:125-135 defines _digitKeys as LogicalKeyboardKey.digit1..digit9 only. _onKey (line 146) matches against that list, so numpad1..numpad9 fall through to ignored. Note that numpadEnter is already handled alongside enter at line 143 — numpad parity is partial.
Fix: extend _digitKeys (or the match in _onKey) to also map LogicalKeyboardKey.numpad1..numpad9 to the same 1-9 selection. Keep the hasPrimaryFocus / modifier guards as-is so digits still type into note fields.
Acceptance: with the prompt card focused, numpad 1-9 selects the corresponding option/button exactly like the number-row digits; numpad keys still type normally when a note field has focus.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 09:39:05', '2026-06-10 09:39:05', NULL, '85f9ce66179451f329a97211dbb96c86', 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 ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing — constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget — each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** — carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** — a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** — the unified collapser. Covers edits, bash, task updates, runs — every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) — the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers — single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser — they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 09:39:52', NULL, 'd173d4e6a58bb9dba5ad279d705f083b', 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 ('06FB170DJA02EH2E8HMH7X7SS4', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'frame0-wireframe skill: pause for user approval after showing each screen', 'The `frame0-wireframe` skill (.claude/skills/frame0-wireframe/SKILL.md) currently runs its Core Workflow straight through — write JSON → push → export → show — and tends to keep generating additional wireframes/screens on its own without stopping. The point of the skill is *dialogue*: each shown screen is a checkpoint for the user to react to, not busywork to batch through.
Adjust the skill so that after step 5 (showing an exported screen inline), it STOPS and waits for explicit user approval/feedback before authoring or exporting the next screen. One screen at a time: show it, ask, then proceed only on a go-ahead. Do not fan out a whole set of wireframes unprompted.
Acceptance: SKILL.md instructs the agent to surface one screen and pause for user approval before continuing; the loop is approval-gated, framing wireframing as a back-and-forth design conversation rather than a one-shot generation run.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:57', '2026-06-10 09:41:52', NULL, 'ed51880a7f0f3c9f29949d91d4c4ad32', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-10 09:42:26', NULL, '6c742b5a63eeb4f907a7f5aa41a98384', 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 ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 09:55:55', NULL, '74171c90e0e0bda3be6590e1e7e536dd', 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 ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 09:56:24', NULL, '43ec3a8962c96601978154295a38cebb', 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 ('06FB1SJKA7KRCQMZE41SPQG780', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Prompt card: numpad digits should select options, not just the number row', 'The Claude prompt card (permission / AskUserQuestion) number-key shortcuts only respond to the top number-row digits, not the numeric keypad. Users on a full keyboard expect numpad 1-9 to pick a button/option too.
Root cause: lib/builtin/claude/src/prompt_card.dart:125-135 defines _digitKeys as LogicalKeyboardKey.digit1..digit9 only. _onKey (line 146) matches against that list, so numpad1..numpad9 fall through to ignored. Note that numpadEnter is already handled alongside enter at line 143 — numpad parity is partial.
Fix: extend _digitKeys (or the match in _onKey) to also map LogicalKeyboardKey.numpad1..numpad9 to the same 1-9 selection. Keep the hasPrimaryFocus / modifier guards as-is so digits still type into note fields.
Acceptance: with the prompt card focused, numpad 1-9 selects the corresponding option/button exactly like the number-row digits; numpad keys still type normally when a note field has focus.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 09:39:05', '2026-06-10 10:01:09', NULL, '5e2f8dfdf163e310d8bc479165ce5130', 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 ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename).
Expected: showing a path always reflects the current file bytes.
Explore / options:
- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change.
- A force-refresh path for clide image show (re-decode, bypass cache).
- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer.
- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing.
Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 10:04:53', NULL, '9179616adf1829faafb62ff32b7ead0a', 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 ('06FB1SJKA7KRCQMZE41SPQG780', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Prompt card: numpad digits should select options, not just the number row', 'The Claude prompt card (permission / AskUserQuestion) number-key shortcuts only respond to the top number-row digits, not the numeric keypad. Users on a full keyboard expect numpad 1-9 to pick a button/option too.
Root cause: lib/builtin/claude/src/prompt_card.dart:125-135 defines _digitKeys as LogicalKeyboardKey.digit1..digit9 only. _onKey (line 146) matches against that list, so numpad1..numpad9 fall through to ignored. Note that numpadEnter is already handled alongside enter at line 143 — numpad parity is partial.
Fix: extend _digitKeys (or the match in _onKey) to also map LogicalKeyboardKey.numpad1..numpad9 to the same 1-9 selection. Keep the hasPrimaryFocus / modifier guards as-is so digits still type into note fields.
Acceptance: with the prompt card focused, numpad 1-9 selects the corresponding option/button exactly like the number-row digits; numpad keys still type normally when a note field has focus.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 09:39:05', '2026-06-10 10:07:19', NULL, '229d4e82ae8b66e249475f49d69bacdb', 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 ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename).
Expected: showing a path always reflects the current file bytes.
Explore / options:
- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change.
- A force-refresh path for clide image show (re-decode, bypass cache).
- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer.
- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing.
Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 10:08:59', NULL, '92b12d703dccdb13e19da14a9d051e6b', 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 ('06FB16YM1Y910T07Y1Y22MGTEM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Card the ''context'' conversation block like the rest of the cards', 'The injected "context" block (skill-load / command-expansion / system-reminder messages) renders as a frameless `ConversationCardVariant.bare` card in `lib/builtin/claude/src/conversation_view.dart:410-419`, so it reads as a bare `> context …` row wedged between the fully-framed Skill and Bash tool cards (see pasted screenshot). It looks unfinished next to the carded tool calls.
Make the context block follow the standard collapser-card pattern established for all group/tool cards in the `collapser-card` wireframe (docs/design/wireframes/cards/): a proper card frame with the chevron toggle hard against the left edge, label, and the fixed-width right-aligned counter/status slot — so it is visually consistent with the Skill/Bash/Activity cards around it.
Keep the de-emphasis that D-78 calls for (muted accent, collapsed-by-default, first-line summary) — this is about giving it the same *frame* as the other cards, not the blue "you" accent. The same treatment likely applies to the sibling bare cards that share this branch (agent prompt, thinking) for consistency; scope to context first and note whether thinking/agent-prompt should follow.
Acceptance: the context row sits in a framed card matching the collapser-card wireframe geometry, still muted and collapsed by default, with the toggle and counter/status slot aligned to the other cards. Update/extend the relevant golden(s).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:42', '2026-06-10 10:09:11', NULL, '647c5ca8b442dd1f86336889e054f779', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-10 10:20:48', NULL, '267a1e8c9bab093fa443f7d8272525b9', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph preview card (clide icon show)', 'A conversation-pane card that renders Phosphor glyphs by name/codepoint, so icons can be previewed and picked in the live pane.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:20:54', NULL, '645c474b9333b748ea355cb0d8191fd9', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph preview card (clide icon show)', 'A conversation-pane card that renders Phosphor glyphs by name/codepoint, so icons can be previewed and picked in the live pane.
## Why
Picking icons (e.g. the settings scope tags, T-302) needs to SEE real glyphs.
Frame0 can''t render Phosphor (private-use codepoints, no font) and golden tests
render the font as Ahem boxes (only painter_bold_metrics loads a real font), so
preview only works where the app has the font — a native card is the vehicle.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a
grid of Phosphor glyphs with their name + codepoint, driven by the clide CLI —
e.g. `clide icon show <name|0xNNNN ...>` or `clide glyphs [filter]` (D-6 parity).
Uses the already-bundled Phosphor.ttf via PhosphorIconPainter.
## Notes / scope
- Every glyph already renders via `PhosphorIconPainter(0xNNNN)`; the 49 named
consts in lib/widgets/src/icons/phosphor.dart are curated sugar. We do NOT
need to bulk-add all ~1512 consts for availability.
- OPTIONAL behind this card: generate the full name→codepoint set (from
.claude/skills/ui-design/references/phosphor-glyphs.md, 1512 entries) so glyphs
are discoverable by name — but only if the card makes them browsable; weigh
against clide''s curated/minimal philosophy.
- The card is display-only (D-78); a click could copy the codepoint/name.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:21:09', NULL, '00bfb44c7719b117d224dfe778a5bffe', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-10 10:21:48', NULL, '0ab1ccc1bfc22b98f08f6e0c58116f2e', 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 ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:24:29', NULL, 'f47e0bdac48ba76f91043659a1d25a97', 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 ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:29:08', NULL, '6616d70e7c621b0f204336ed29d40bf0', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph preview card (clide icon show)', 'A conversation-pane card that renders Phosphor glyphs by name/codepoint, so icons can be previewed and picked in the live pane.
## Why
Picking icons (e.g. the settings scope tags, T-302) needs to SEE real glyphs.
Frame0 can''t render Phosphor (private-use codepoints, no font) and golden tests
render the font as Ahem boxes (only painter_bold_metrics loads a real font), so
preview only works where the app has the font — a native card is the vehicle.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a
grid of Phosphor glyphs with their name + codepoint, driven by the clide CLI —
e.g. `clide icon show <name|0xNNNN ...>` or `clide glyphs [filter]` (D-6 parity).
Uses the already-bundled Phosphor.ttf via PhosphorIconPainter.
## Notes / scope
- Every glyph already renders via `PhosphorIconPainter(0xNNNN)`; the 49 named
consts in lib/widgets/src/icons/phosphor.dart are curated sugar. We do NOT
need to bulk-add all ~1512 consts for availability.
- OPTIONAL behind this card: generate the full name→codepoint set (from
.claude/skills/ui-design/references/phosphor-glyphs.md, 1512 entries) so glyphs
are discoverable by name — but only if the card makes them browsable; weigh
against clide''s curated/minimal philosophy.
- The card is display-only (D-78); a click could copy the codepoint/name.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:38:06', NULL, '3852ea0ab0ba8510fd4fbf1fa6d20239', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant scale is the inline type scale in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive sizing off those tokens (not bare numbers) so the row tracks the scale if it changes; the hero size can be a named large constant. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape
- Trivial case stays terse, mirroring image show: `clide icon show <name|0xNNNN> [<name> ...]` renders the bare glyph(s) with name + codepoint.
- Labelled/described case takes a structured payload (per entry has up to three fields, so flags get unwieldy): a JSON array via `--stdin`/`--file`, each item `{"icon": "gear", "label": "Settings", "description": "global scope"}` where label and description are optional. icon resolves by kebab-case name (PhosphorIcons.byName) or 0xNNNN codepoint.
- One card per invocation; the entries render as rows (or a grid when label/description are absent).
## Display-only vs. selectable (D-78 — key boundary)
The card itself is a CONVERSATION WIDGET and stays display-only (D-78): it shows the labelled icon options and a click may copy the codepoint/name, but it does NOT resolve a selection inline. When the offer needs to be an actual PICK that returns a choice, that selection belongs in the interaction zone (AskUserQuestion-style option list that replaces the composer), NOT inline in the transcript — see the interaction-zone rule. So scope this ticket to the display/offer card; an interaction-zone icon picker (or feeding these entries into an AskUserQuestion option list) is a follow-up, not part of this card. Flag which path we want before building the selectable variant.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `clide icon show` accepts multiple icons in one call and renders them in a single conversation card.
- A structured (JSON) input lets each icon carry an optional label and optional description, both rendered alongside the glyph.
- Each icon renders at multiple sizes: a large hero plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); the description records that a selectable picker routes through the interaction zone as a separate follow-up.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:41:56', NULL, '6b462c12d7c4b839c4b5affd6e1546b1', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering at 48px so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. The 48px hero is larger than any existing type token (welcome banner is 52, dialog title 16), so add a named preview constant for it (e.g. clideIconPreviewHero = 48) rather than a bare 48 — keep the scale coherent. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape
- Trivial case stays terse, mirroring image show: `clide icon show <name|0xNNNN> [<name> ...]` renders the bare glyph(s) with name + codepoint.
- Labelled/described case takes a structured payload (per entry has up to three fields, so flags get unwieldy): a JSON array via `--stdin`/`--file`, each item `{"icon": "gear", "label": "Settings", "description": "global scope"}` where label and description are optional. icon resolves by kebab-case name (PhosphorIcons.byName) or 0xNNNN codepoint.
- One card per invocation; the entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `clide icon show` accepts multiple icons in one call and renders them in a single conversation card.
- A structured (JSON) input lets each icon carry an optional label and optional description, both rendered alongside the glyph.
- Each icon renders at multiple sizes: a 48px hero (new named constant, e.g. clideIconPreviewHero) plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:43:01', NULL, '04e889f18335733517fba43971eb67f4', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape
- Trivial case stays terse, mirroring image show: `clide icon show <name|0xNNNN> [<name> ...]` renders the bare glyph(s) with name + codepoint.
- Labelled/described case takes a structured payload (per entry has up to three fields, so flags get unwieldy): a JSON array via `--stdin`/`--file`, each item `{"icon": "gear", "label": "Settings", "description": "global scope"}` where label and description are optional. icon resolves by kebab-case name (PhosphorIcons.byName) or 0xNNNN codepoint.
- One card per invocation; the entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `clide icon show` accepts multiple icons in one call and renders them in a single conversation card.
- A structured (JSON) input lets each icon carry an optional label and optional description, both rendered alongside the glyph.
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:44:06', NULL, '9a25fe730b81f5192fb4142d66fbff85', 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 ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:46:25', NULL, 'aa95db9de3811c6058836e661dd304e8', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:49:59', NULL, 'a2281b4ce92c45a7783d48bc8b74b872', 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 ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide CLI: accept a JSON payload on stdin for structured commands', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-10 10:52:34', NULL, '12a5619d2d8d756bc4b703094a28fd69', 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 ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-10 10:52:36', NULL, '7f9a9723a231cfda8e8b05961487b50d', 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 ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-10 10:55:30', NULL, '918b4d6d1b3e3e45c7431196419cf40d', 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 ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption.
## Why
Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces.
## Deliverable
Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file <path.json>` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.:
{ "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" }
Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show <path> --caption "…"` form working unchanged (back-compat); the JSON payload is additive.
## Open decision — text vs visual annotation
''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers.
## Dependencies
- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready.
- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path.
## Acceptance
- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption.
- ImageMessage + the image card render the added metadata.
- The existing `image show <path> [--caption]` form is unchanged.
- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation.
- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-10 10:55:34', NULL, '6f46462a2482a842fe606d13da6905cd', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.
FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:55:49', NULL, 'e2a3afff7620d41cf75d0b55c90543df', 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 ('06FB2EDCBYRBDSV9V1PJ1KE3CM', 'epic', NULL, 'Unified conversation drawing card (canvas renderer)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:10:07', '2026-06-10 11:10:07', NULL, 'f62f2ff781a057083ca0dc6855824778', 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 ('06FB2EDCBYRBDSV9V1PJ1KE3CM', 'epic', NULL, 'Unified conversation drawing card (canvas renderer)', 'Epic for the unified conversation drawing card — ONE clide-owned canvas renderer driven by a JSON document, replacing the trend of one-off conversation renderers (image card, icon card, …). See decision D-91.
## The model
Think HTML `<canvas>`, NOT Obsidian''s `.canvas` (Obsidian naming its feature ''canvas'' doesn''t make its schema our pattern). The card''s JSON can express raw PRIMITIVES (rects/lines/text at coordinates — ''draw a box at x,y, a line from x,y to x,y'' works), but in the common case it runs in TEMPLATE mode: the JSON names a predefined component and the card grabs it. Templates lower onto the same primitive scene the raw API accepts (hybrid: scene-graph core + high-level block sugar).
Per drawn object there is an optional label + description widget rendered BENEATH it, shown only when those fields are present in the JSON.
The card is DISPLAY-ONLY (D-78) — selection/interaction happens in the interaction zone, never on the card. The labels are the bridge to interaction-zone choice lists (see T-313).
## Template set (expand as we go)
- image — display the file, lightbox on click
- icon — the multi-size glyph set (T-313)
- compare-images / before-after — two (or more) paths side by side, each with its label/description
- svg — render an SVG file
- graph — render a graph
- …more added incrementally; ''we will expand the templates as we go''
## Reuse goal
The same renderer is intended for reuse as the `.canvas` VIEWER elsewhere: a `.canvas` file is converted into this JSON (not the card adopting Obsidian''s schema natively). Narrows Q-4.
## Input
Driven via the clide CLI (D-6 parity), consuming the JSON-payload plumbing — `--file` and the stdin path (T-315).
## Children / related
- Core canvas engine (primitives + template dispatch + per-object label/description) — foundational.
- Per-template tickets: image, icon (T-313), compare/before-after, svg, graph.
- T-315 — stdin/--file JSON input plumbing (input channel).
- T-316 — image annotation metadata (folds in as the image/compare template''s label/description/markers).
- `.canvas` -> JSON converter / reuse (deferred).
- Existing image card (T-249/T-252): LEFT AS-IS for now; migrated onto the canvas once the canvas layer lands.
## Scope note
This is a real renderer with a schema, not a one-off widget. The schema must stay coherent across the primitive and template layers as templates accrue (D-91 cost). Start with a true canvas + the first templates; grow the template set over time.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:10:07', '2026-06-10 11:10:59', NULL, 'bc20218bb8e27a91cd16929d5b99352d', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.
FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 11:11:10', NULL, '55c15feee383840b59617058ed513421', 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 ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-10 11:11:10', NULL, '69251007038985decbc3e18585f2d7b1', 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 ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption.
## Why
Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces.
## Deliverable
Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file <path.json>` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.:
{ "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" }
Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show <path> --caption "…"` form working unchanged (back-compat); the JSON payload is additive.
## Open decision — text vs visual annotation
''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers.
## Dependencies
- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready.
- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path.
## Acceptance
- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption.
- ImageMessage + the image card render the added metadata.
- The existing `image show <path> [--caption]` form is unchanged.
- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation.
- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-10 11:11:10', NULL, 'ab80fa0823568f0d159b6a9d162114b2', 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 ('06FB2ERREMEEF26KKHGNZBWW64', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card core: canvas engine + JSON schema + template dispatch', 'Foundational build for the unified drawing card (epic T-317, decision D-91). A clide-owned canvas (CustomPaint) that renders from a JSON document: a PRIMITIVE scene-graph layer (rect/line/text/glyph/image at coordinates) plus a TEMPLATE-dispatch layer that maps a named component in the JSON to a predefined renderer. Templates lower onto the same primitive scene (hybrid model). Per drawn object, an optional label + description widget renders beneath it, only when those fields are present in the JSON. Display-only (D-78) — no inline selection. Driven via the clide CLI (D-6 parity), consuming the JSON input plumbing (T-315 / --file); mirror image.show''s Flutter-free handler + MessageBus publish + Claude-extension injection pattern. THIS ticket = the engine, the JSON schema, the primitive renderer, the dispatch mechanism, and the shared per-object label/description widget. Individual templates (image, icon, compare, svg, graph) are separate children. Acceptance: a JSON doc with raw primitives draws; a JSON doc naming a template dispatches to it; an object with label/description renders the caption widget; unknown template/primitive fails with a clear userError.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:40', '2026-06-10 11:11:40', NULL, '73af1f59dfa92fcb13073cd31a73e2f4', 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 ('06FB2ESCR4V6V07CRH18FBCDN8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: compare-images / before-after', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:45', '2026-06-10 11:11:45', NULL, '5d2d21c180849ce0bb652d79855c40af', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: SVG render', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-10 11:11:55', NULL, 'aee93f5693c771bedec77e288a2784b1', 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 ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-10 11:11:59', NULL, 'd21ba3f471f6518d5775008897e67416', 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 ('06FB16YM1Y910T07Y1Y22MGTEM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Card the ''context'' conversation block like the rest of the cards', 'The injected "context" block (skill-load / command-expansion / system-reminder messages) renders as a frameless `ConversationCardVariant.bare` card in `lib/builtin/claude/src/conversation_view.dart:410-419`, so it reads as a bare `> context …` row wedged between the fully-framed Skill and Bash tool cards (see pasted screenshot). It looks unfinished next to the carded tool calls.
Make the context block follow the standard collapser-card pattern established for all group/tool cards in the `collapser-card` wireframe (docs/design/wireframes/cards/): a proper card frame with the chevron toggle hard against the left edge, label, and the fixed-width right-aligned counter/status slot — so it is visually consistent with the Skill/Bash/Activity cards around it.
Keep the de-emphasis that D-78 calls for (muted accent, collapsed-by-default, first-line summary) — this is about giving it the same *frame* as the other cards, not the blue "you" accent. The same treatment likely applies to the sibling bare cards that share this branch (agent prompt, thinking) for consistency; scope to context first and note whether thinking/agent-prompt should follow.
Acceptance: the context row sits in a framed card matching the collapser-card wireframe geometry, still muted and collapsed by default, with the toggle and counter/status slot aligned to the other cards. Update/extend the relevant golden(s).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:42', '2026-06-10 11:16:41', NULL, '5b583df100b64dbebe19c235f32e0541', 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-06-10 11:17:17', NULL, 'a2f956a7f232bf7cd3d1e05dc679f1d7', 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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-06-10 11:17:23', NULL, 'cd97babed081736efcba6756ffec2ead', 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 ('06FB0TNQM7162Z26FDWXG35VH0', 'epic', NULL, 'Tier 5 — canvas and graph view', 'Tier 5 of the build plan: bring the canvas pane (Obsidian-style spatial whiteboard) and the graph view (force-directed view of vault links) to feature parity.
**Canvas pane (`builtin.canvas`):**
- Render `.canvas` files (Obsidian-compatible JSON) — nodes (note, text, group, image), edges, layout state.
- Pan/zoom via `InteractiveViewer`; node selection, drag, resize.
- Edit affordances: add note from file picker, add text node, draw edge between nodes.
- Persist layout back to the `.canvas` file on disk.
**Graph view (`builtin.graph`):**
- Force-directed layout of the current vault (or a filtered subset) — nodes are notes, edges are wikilinks.
- Hover highlights connected subgraph; click opens the note in the editor.
- Filter pane: tag include/exclude, file glob, depth-from-active.
- pql provides the link data (`pql backlinks` / `pql outlinks`); rendering owned in-app.
Both panes use the existing `MultitabPane` (T-83) for tab management once they ship. Each lives in its own slot per D-47 (canvas in workspace, graph in context panel).
MERGED (2026-06-10) into the unified canvas epic T-317 (decision D-91). The canvas rendering work consolidates onto one clide-owned canvas renderer driven by JSON; .canvas becomes an import format converted into that JSON rather than a native schema. T-7''s scope is preserved as children of T-317: canvas pane -> T-322, graph view -> T-323. This epic is cancelled as superseded; track the work under T-317.', 'backlog', 'medium', NULL, NULL, 'D-17', '2026-04-22 11:45:41', '2026-06-10 11:17:31', NULL, 'ff3683fb066e070dda96af9c5eeaf943', 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 ('06FB0TNQM67JSC5RKS6M9182KG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Add images tab to context panel', 'Spec lists images as a right-panel section. No extension exists yet.', 'backlog', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 11:18:12', NULL, '17e5d66435d973f3771be7ac93104262', 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 ('06FB0TNQM7162Z26FDWXG35VH0', 'epic', NULL, 'Tier 5 — canvas and graph view', 'Tier 5 of the build plan: bring the canvas pane (Obsidian-style spatial whiteboard) and the graph view (force-directed view of vault links) to feature parity.
**Canvas pane (`builtin.canvas`):**
- Render `.canvas` files (Obsidian-compatible JSON) — nodes (note, text, group, image), edges, layout state.
- Pan/zoom via `InteractiveViewer`; node selection, drag, resize.
- Edit affordances: add note from file picker, add text node, draw edge between nodes.
- Persist layout back to the `.canvas` file on disk.
**Graph view (`builtin.graph`):**
- Force-directed layout of the current vault (or a filtered subset) — nodes are notes, edges are wikilinks.
- Hover highlights connected subgraph; click opens the note in the editor.
- Filter pane: tag include/exclude, file glob, depth-from-active.
- pql provides the link data (`pql backlinks` / `pql outlinks`); rendering owned in-app.
Both panes use the existing `MultitabPane` (T-83) for tab management once they ship. Each lives in its own slot per D-47 (canvas in workspace, graph in context panel).
MERGED (2026-06-10) into the unified canvas epic T-317 (decision D-91). The canvas rendering work consolidates onto one clide-owned canvas renderer driven by JSON; .canvas becomes an import format converted into that JSON rather than a native schema. T-7''s scope is preserved as children of T-317: canvas pane -> T-322, graph view -> T-323. This epic is cancelled as superseded; track the work under T-317.', 'cancelled', 'medium', NULL, NULL, 'D-17', '2026-04-22 11:45:41', '2026-06-10 11:18:15', NULL, '3e558a94afc3e675e673c6e9f9390855', 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 ('06FB1ZFJK6J2GSV4SA69QF730C', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Image card caches by path — overwritten file shows stale render', 'clide image show (and the conversation image viewer card) appears to cache by file path: re-showing a file after its bytes change at the SAME path renders the previous image, not the new one. Hit live while iterating on a Frame0 wireframe (re-exported settings-modal.png in place; the pane kept showing the prior version until exported under a new filename).
Expected: showing a path always reflects the current file bytes.
Explore / options:
- Cache-key on mtime + size (or a content hash) instead of path alone; invalidate when they change.
- A force-refresh path for clide image show (re-decode, bypass cache).
- Check where the cache lives: the image card widget (Image.file / ImageProvider cache — Flutter''s imageCache keys by path+scale and won''t refresh on overwrite), the IPC image.show handler, or the pane render layer.
- Likely Flutter imageCache: evict the key (or use a FileImage with a key that includes mtime) before showing.
Note: surfaced via clide image show (T-249 / T-252 lightbox share the same provider). Repro: export an image, show it, overwrite the same path with different content, show again -> stale.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:04:53', '2026-06-10 11:25:58', NULL, 'e0db833047273042eaa96ba6b54bed8d', 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 ('06FB2J2HWD66QAFDDRRWS5NM48', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Tab strip feels cramped against the chrome above — add 1px margin on top', 'The tab strip sits flush against the chrome/border directly above it, which reads as cramped (user screenshot: the ''primary'' / ''session 1'' tabs butting up against the top edge). Add ~1px of breathing room above the tab strip.
The tab strip is ClideTabBar (lib/widgets/src/clide_tab_bar.dart) — a Container with a fixed height + tabBarBackground color, no top margin/padding. Options: add a 1px top margin on the ClideTabBar Container, or have the host that places it (e.g. multitab_pane.dart / the pane chrome) reserve 1px above. Prefer doing it where it won''t double-apply across the several surfaces that embed a tab bar (editor tabs, sidebar tabs, session tabs) — confirm it looks right in each, not just the Claude session host in the screenshot.
Keep it a hairline (1px) so it relieves the cramping without shifting the layout noticeably. Watch the tabBarBackground vs the surface behind it so the 1px gap shows the intended color, not a seam.
Acceptance: the tab strip has 1px of space above it; tabs no longer butt against the chrome edge; checked across the surfaces that use ClideTabBar.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:26:07', '2026-06-10 11:26:07', NULL, '7b1fc03526ce721346981e261e130b8f', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302).
## Why
Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list.
## Deliverable
A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter.
### Multi-size rendering (per entry)
Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands.
### CLI shape + IPC wiring (mirror image.show exactly)
Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern:
- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`.
- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing.
- LABELLED/DESCRIBED entries: a `--file <path.json>` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work.
- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show.
- One card per invocation; entries render as rows (or a grid when label/description are absent).
## Display-only card + interaction-zone selection (D-78 — decided)
DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule.
The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself.
## Notes / scope
- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar.
- Every glyph already renders via PhosphorIconPainter(0xNNNN).
- label and description are both optional per entry; an entry with neither degrades to the bare-preview look.
- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists.
## Acceptance
- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension.
- `clide icon show <name> [<name> ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card.
- A `--file <path.json>` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser).
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.
FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 11:29:04', NULL, 'c09cb1299d29d33380f6f33d05528345', 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 ('06FB2J2HWD66QAFDDRRWS5NM48', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Tab strip feels cramped against the chrome above — add 1px margin on top', 'The tab strip sits flush against the chrome/border directly above it, which reads as cramped (user screenshot: the ''primary'' / ''session 1'' tabs butting up against the top edge). Add ~1px of breathing room above the tab strip.
The tab strip is ClideTabBar (lib/widgets/src/clide_tab_bar.dart) — a Container with a fixed height + tabBarBackground color, no top margin/padding. Options: add a 1px top margin on the ClideTabBar Container, or have the host that places it (e.g. multitab_pane.dart / the pane chrome) reserve 1px above. Prefer doing it where it won''t double-apply across the several surfaces that embed a tab bar (editor tabs, sidebar tabs, session tabs) — confirm it looks right in each, not just the Claude session host in the screenshot.
Keep it a hairline (1px) so it relieves the cramping without shifting the layout noticeably. Watch the tabBarBackground vs the surface behind it so the 1px gap shows the intended color, not a seam.
Acceptance: the tab strip has 1px of space above it; tabs no longer butt against the chrome edge; checked across the surfaces that use ClideTabBar.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 11:26:07', '2026-06-10 11:29:10', NULL, 'a41dd77ce4d26779d83e385a0701a2a8', 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 ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 11:33:18', NULL, '99cabbaf7cef885905c6e3d44c8f9441', 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 ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 11:33:26', NULL, '72831c0e631550beddea279fa12f0657', 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 ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-10 11:33:35', NULL, 'a4e3c0840be6bc811fb82308051e6239', 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 ('06FB2J2HWD66QAFDDRRWS5NM48', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Tab strip feels cramped against the chrome above — add 1px margin on top', 'The tab strip sits flush against the chrome/border directly above it, which reads as cramped (user screenshot: the ''primary'' / ''session 1'' tabs butting up against the top edge). Add ~1px of breathing room above the tab strip.
The tab strip is ClideTabBar (lib/widgets/src/clide_tab_bar.dart) — a Container with a fixed height + tabBarBackground color, no top margin/padding. Options: add a 1px top margin on the ClideTabBar Container, or have the host that places it (e.g. multitab_pane.dart / the pane chrome) reserve 1px above. Prefer doing it where it won''t double-apply across the several surfaces that embed a tab bar (editor tabs, sidebar tabs, session tabs) — confirm it looks right in each, not just the Claude session host in the screenshot.
Keep it a hairline (1px) so it relieves the cramping without shifting the layout noticeably. Watch the tabBarBackground vs the surface behind it so the 1px gap shows the intended color, not a seam.
Acceptance: the tab strip has 1px of space above it; tabs no longer butt against the chrome edge; checked across the surfaces that use ClideTabBar.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 11:26:07', '2026-06-10 11:36:35', NULL, '06aaa8609442446ebb98cd83cf241450', 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 ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.
**Initial trial**
- For the first cut, double the current size: the `size: 12` activity-card call sites go to `size: 24`. Trial that footprint, then settle the final value in review against the control-geometry tokens.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-10 11:55:15', NULL, 'f1b11537f6c416bae19a6da5447a384e', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-10 12:00:52', NULL, '96c66c13b16a312fa2ae625817269bb0', 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 ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', NULL, 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:04:51', NULL, '6543e76a19006cfc6cd6ff85cd2c2d6e', 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 ('06FB0TNQM4KS233FGZE9H7ABWR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Activity-card spinner too small to make out — enlarge it', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
**Where**
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
**Direction (settle in review)**
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
**Acceptance**
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.
**Initial trial**
- For the first cut, double the current size: the `size: 12` activity-card call sites go to `size: 24`. Trial that footprint, then settle the final value in review against the control-geometry tokens.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 21:22:28', '2026-06-10 12:05:17', NULL, '1266a6e25abae600315c65aefd7d8f93', 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 ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:05:21', NULL, 'c6de783bb8c7c95fa548861ec4c74d4f', 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 ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:05:39', NULL, '36855b40b1014791247074c28862e0cd', 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 ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', 'ready', 'high', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 12:07:39', NULL, '3f833daa3bb004ea42b2f0d70f6cc19b', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 12:08:14', NULL, '5897237a09dcf146461d532806e0a118', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 12:08:18', NULL, 'bb9d23c8e18d23c95ddbcdf7595f9b35', 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 ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly.
**Interaction**
- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points).
- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it.
**Where (file:line)**
- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed.
- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730).
- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127).
**Use the message bus (required)**
- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', ''<channel>'', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144).
- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': <full ticket + instruction text> })`.
- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule.
**Edge cases (settle in review)**
- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review.
- Busy session (mid-turn): queue the inject vs. block with a hint.
- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in).
**Acceptance**
- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered.
- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin.
- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only.
- Clicking with no focused Claude pane degrades gracefully (no crash).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:10:04', NULL, 'a23c4f546ae8408988106e0c98fcac03', 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 ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly.
**Interaction**
- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points).
- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it.
**Where (file:line)**
- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed.
- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730).
- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127).
**Use the message bus (required)**
- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', ''<channel>'', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144).
- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': <full ticket + instruction text> })`.
- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule.
**Edge cases (settle in review)**
- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review.
- Busy session (mid-turn): queue the inject vs. block with a hint.
- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in).
**Acceptance**
- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered.
- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin.
- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only.
- Clicking with no focused Claude pane degrades gracefully (no crash).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:12:30', NULL, '0847120e70ae275e3e115478d092c283', 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 ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Permission card: add ''Deny & simplify'' option that denies with a retry-simpler note', 'Add a fourth option to the Claude permission prompt card (lib/builtin/claude/src/prompt_card.dart) alongside Allow / Allow & don''t ask again / Deny.
WHAT IT DOES
A deny that carries a preformatted follow-up note telling Claude the action was too complex for the permission system and to retry in a simpler format. Implemented as a _permDeny variant that passes a fixed note into DenyTool (today _permDeny uses _permNote() ?? ''Denied by the user.'' at prompt_card.dart:202). The user''s own note field, if filled, should still be respected — append it to / combine with the preformatted text rather than discard it.
LABEL: ''Deny & simplify'' (working label; placed after Deny).
TOOLTIP: ClideButton already supports (clide_button.dart:26) — add a mouseover explaining the behavior, e.g. ''Deny and ask Claude to retry this action in a simpler format — complex interactions don''t work well with the permission system.''
PREFORMATTED DENY NOTE (workshop wording, starting point):
"Denied — this action is too complex for the permission system to approve cleanly. Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not change permission settings — just reformulate and try again."
The ''do not add a memory / do not change permission settings'' clause is deliberate: without it Claude tends to ''fix'' the permission system (writing memories, rewriting permission config), which means continuous fiddling with a surface we don''t want it touching.
NUMBER-KEY SLOT
_activateNumber (prompt_card.dart:161-164) maps 1=Allow, 2=Allow&remember (when canRemember), 3/2=Deny. Add the new option as the next index (4 when canRemember, else 3). Keep Deny as its own option; the new one is additive. Update the digit/numpad shortcut mapping accordingly (see also T-310 numpad parity).
DESIGN NOTE — escalation behavior
The deny note enters the conversation and stays in context, so repeated use within one session compounds (Claude leans progressively harder toward simpler formats). That''s largely the intended escalating pressure, but the note is phrased as a one-shot ''retry THIS action'' rather than a standing rule to limit over-correction. Worth watching in testing whether repeated denials over-bias toward trivial formats.
ACCEPTANCE
- A fourth button ''Deny & simplify'' appears on the permission card with a tooltip.
- Activating it resolves the prompt as a deny whose note is the preformatted retry-simpler text (with the user''s typed note appended when present).
- The note explicitly tells Claude not to add a memory or change permission settings.
- Number-row and numpad digit shortcuts address the new option in the correct slot.
- Widget test covers the new button resolving to a DenyTool with the expected note.
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 12:14:00', NULL, '995bc6dbbc7231306303b838aba8a2eb', 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 ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 12:31:23', NULL, 'ddfb6e694a6c5b6e3bc39132cd57318c', 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 ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
Scope:
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
- a11y: list semantics, status announced per item.
Acceptance:
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
2. Empty/no-task sessions show nothing (no empty chrome).
3. Collapsed by default with a one-line summary; expandable to the full list.
4. Display-only; the composer keeps full function and focus behaviour.
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 12:32:07', NULL, '9ed7b932a0448a813e89c462e383b88a', 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 ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly.
**Interaction**
- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points).
- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it.
**Where (file:line)**
- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed.
- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730).
- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127).
**Use the message bus (required)**
- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', ''<channel>'', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144).
- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': <full ticket + instruction text> })`.
- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule.
**Edge cases (settle in review)**
- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review.
- Busy session (mid-turn): queue the inject vs. block with a hint.
- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in).
**Acceptance**
- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered.
- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin.
- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only.
- Clicking with no focused Claude pane degrades gracefully (no crash).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:51:50', NULL, 'a9982bdc3ed2e36cc57787e4582c4e46', 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 ('06FB1S7613SYF0M9XQT5JNWM40', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Lightbox: click anywhere outside the image should dismiss', 'When the image lightbox is open, clicking outside the image should close it, matching Esc and the close (x) button.
Today only the thin margin around the lightbox dismisses. The DialogRouter host (lib/kernel/src/dialog.dart:95-103) renders a backdrop that dismisses on tap, but wraps the dialog content in a tap-swallowing GestureDetector (onTap: () {}). ClideLightbox fills a 94%-of-screen SizedBox whose Positioned.fill InteractiveViewer (lib/widgets/src/clide_lightbox.dart:93-113) covers the entire box. So taps on the dark area beside a smaller-than-94% image hit the swallower, not the backdrop, and nothing happens.
Fix: make taps that land outside the actual image bounds dismiss — e.g. add an onTap to the lightbox''s outer GestureDetector that calls onDismiss when the tap is not on the image (InteractiveViewer child), rather than relying on the host backdrop only reaching the 6% margin. Preserve double-tap-to-reset, scroll-to-zoom, and pan; a single tap on empty canvas closes, a tap/drag on the image does not.
Acceptance: single click on the dimmed area surrounding the image closes the lightbox; clicking/dragging/zooming the image does not; Esc and the x still close.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 09:37:31', '2026-06-10 12:56:38', NULL, '9c888af2ae0a8621ae428ac090508e0c', 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 ('06FB37JZSFZKWPK9PDFYJY2YC0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Deny & simplify note: don''t narrate the reformulation, just retry simpler', 'The T-311 ''Deny & simplify'' note prompts Claude to retry in a simpler form, but Claude tends to narrate the change (''right, splitting that command'', etc.) instead of silently retrying. Add a clause to the preformatted note (lib/builtin/claude/src/prompt_card.dart _kDenySimplifyNote) telling Claude NOT to narrate or re-explain the changes it makes — just proceed as it was, only simpler. Target: a quiet simpler retry, no commentary. Surfaced live while the button was tested against git commands.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 13:00:06', '2026-06-10 13:00:06', NULL, 'b6ed8a4e8b82ec89ca361032a3a0ba8c', 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 ('06FB37JZSFZKWPK9PDFYJY2YC0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Deny & simplify note: don''t narrate the reformulation, just retry simpler', 'The T-311 ''Deny & simplify'' note prompts Claude to retry in a simpler form, but Claude tends to narrate the change (''right, splitting that command'', etc.) instead of silently retrying. Add a clause to the preformatted note (lib/builtin/claude/src/prompt_card.dart _kDenySimplifyNote) telling Claude NOT to narrate or re-explain the changes it makes — just proceed as it was, only simpler. Target: a quiet simpler retry, no commentary. Surfaced live while the button was tested against git commands.', 'ready', 'low', NULL, NULL, NULL, '2026-06-10 13:00:06', '2026-06-10 13:00:19', NULL, '19e1551d324a36c8a2d737265847c448', 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 ('06FB2TY91VHK7TPKPMZ11EG3TM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ClideStatusIndicator: duplicate ''running'' key crashes AnimatedSwitcher on rapid status flips', 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 12:04:51', '2026-06-10 13:00:29', NULL, '8e5bd637d25313c220c5565e54ee657f', 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 ('06FB1S7613SYF0M9XQT5JNWM40', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Lightbox: click anywhere outside the image should dismiss', 'When the image lightbox is open, clicking outside the image should close it, matching Esc and the close (x) button.
Today only the thin margin around the lightbox dismisses. The DialogRouter host (lib/kernel/src/dialog.dart:95-103) renders a backdrop that dismisses on tap, but wraps the dialog content in a tap-swallowing GestureDetector (onTap: () {}). ClideLightbox fills a 94%-of-screen SizedBox whose Positioned.fill InteractiveViewer (lib/widgets/src/clide_lightbox.dart:93-113) covers the entire box. So taps on the dark area beside a smaller-than-94% image hit the swallower, not the backdrop, and nothing happens.
Fix: make taps that land outside the actual image bounds dismiss — e.g. add an onTap to the lightbox''s outer GestureDetector that calls onDismiss when the tap is not on the image (InteractiveViewer child), rather than relying on the host backdrop only reaching the 6% margin. Preserve double-tap-to-reset, scroll-to-zoom, and pan; a single tap on empty canvas closes, a tap/drag on the image does not.
Acceptance: single click on the dimmed area surrounding the image closes the lightbox; clicking/dragging/zooming the image does not; Esc and the x still close.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 09:37:31', '2026-06-10 13:15:15', NULL, '2c16b97db9098d73e122bfbaba2b74eb', 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 ('06FB37JZSFZKWPK9PDFYJY2YC0', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Deny & simplify note: don''t narrate the reformulation, just retry simpler', 'The T-311 ''Deny & simplify'' note prompts Claude to retry in a simpler form, but Claude tends to narrate the change (''right, splitting that command'', etc.) instead of silently retrying. Add a clause to the preformatted note (lib/builtin/claude/src/prompt_card.dart _kDenySimplifyNote) telling Claude NOT to narrate or re-explain the changes it makes — just proceed as it was, only simpler. Target: a quiet simpler retry, no commentary. Surfaced live while the button was tested against git commands.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 13:00:06', '2026-06-10 13:18:57', NULL, 'd63c15b0903f860a85dd1b437e96c388', 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 ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:24:15', NULL, '75c7f89b46b83a29e547f7efe3314826', 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 ('06FB3DHCTP001YCHFP39XER0ZM', 'epic', NULL, 'SSH-remote workspaces', 'Run clide locally but open and work in a git repo that lives on a remote machine over SSH. All shell actions (git, pql, the claude process, terminal panes, file I/O) execute remotely; only inherently-local things (clipboard / image paste) stay local and bridge their bytes across. Resolves Q-23. The user''s original ''pql binary local, db+repo remote'' hypothesis is not viable (pql reads its .pql/ index off the filesystem, git needs .git/ local, inotify does not cross sshfs) — so the cut is: execution is remote, UI + clipboard stay local, bridged by SSH. Load-bearing tension: execution must be remote, but the user does not want to install clide components on the remote or manage versions there — the footprint model is deferred to a Phase-0 spike. Locked: auth reuses system ssh + ~/.ssh/config for v1 (Windows is a known gap); v1 scope is full backend remote (files, git, pql, terminal, native Claude pane). Plan: ~/.claude/plans/can-we-plan-an-sleepy-gem.md', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:06', '2026-06-10 13:26:06', NULL, '28d37835a16accafb3eab563ad4003e3', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-10 13:26:19', NULL, '2ab1c13b28a4bd6a78b9e7aad88ab438', 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 ('06FB3DKQQJ583944DG8561VQ3G', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 1 — backend transport seam (DaemonTransport, prove locally)', 'Model-independent backbone. Make the local app talk to its backend through a DaemonTransport abstraction with ZERO behavior change, so a remote transport can slot in later. New lib/src/ipc/transport.dart: DaemonTransport interface + LocalTransport (wraps today''s unix-socket connect). DaemonClient takes a transport instead of a hard-coded socket path; its reconnect loop generalizes to ''re-establish via transport''. lib/main.dart: swapIpcServer (~:119) becomes swapBackend — local binds the in-process IpcServer as today; seam ready for a remote branch. Wire protocol unchanged — JSON-lines IpcRequest/IpcResponse/IpcEvent (envelope.dart) already serialize over IPC; event stream, tail --events, events --since cursor-pull ride the same stream untouched. Verify: make test-core (IPC) + testmode skill — local path behaves identically through the seam, no regression. Depends on Phase 0 (T-330).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:25', '2026-06-10 13:26:25', NULL, '8aa11d9791af3d5653cf33260ed7b1ae', 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).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-10 13:26:31', NULL, 'a75b65a2775abc2852b81c2f1a078847', 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 ('06FB3DN94MBCTYJW17ZCYVSXE0', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 3 — Claude remoting (stdio control channel)', 'Model-independent backbone. Native Claude pane works against a remote workspace. claude is spawned where the repo is (remote), workingDirectory = remote repo; its --resume transcripts live on the remote under ~/.claude/... — correct and automatic. New long-lived IPC verb claude.control.attach (modeled on the tail --events long-lived connection, server.dart ~:302) relays the stdio control protocol both directions, so canUseTool / AskUserQuestion prompts render natively locally and answers return over the tunnel (D-77/D-78). lib/builtin/claude/src/session_naming.dart: re-key the session seed on (host, repo) so the same path on two hosts (or local) is two sessions (D-41/D-77 amendment from Phase 0). lib/builtin/claude/src/agent_bootstrap.dart: CLIDE_SOCK/CLIDE_WORKSPACE/PATH describe the remote socket + workspace (computed remote-side). Verify: drive a Claude turn against the loopback-remote workspace; confirm a permission prompt renders natively and the answer flows back; confirm --resume re-attaches the remote transcript. Depends on Phase 2 + the execution-layer fork.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:38', '2026-06-10 13:26:38', NULL, 'b961853eda518a7508558d5c8d85fc87', 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 ('06FB3DNQZKV20F7YG5PJH8V8SM', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 4 — image-paste local->remote bridge', 'Model-independent backbone. Paste an image from the local clipboard, reference it from remote Claude. Clipboard read stays local (clipboard_paste.dart, clide/clipboard MethodChannel) — clipboard is inherently local. New IPC verb files.put (write bytes remote-side) in lib/src/daemon/files_commands.dart — the only local->remote byte push. After writing the local temp image, push bytes to a remote scratch dir and rewrite ComposerAttachment.path to the remote path before building the @<path> token, so remote claude resolves it. New minor D-record: scratch-dir location (<repo>/.clide/pasted vs remote cache), tying into ignore discipline (D-4; .clide/ is already ignore-handled). Verify: paste a screenshot locally, confirm bytes land remotely and remote Claude reads the @path. Depends on Phase 2 + execution-layer fork.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:26:42', '2026-06-10 13:26:42', NULL, '85987f1719a16078b095a9d9ba8c8e14', 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 ('06FB3DP48FS33CQGRDF7EB9GT0', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 5 — UI: remote connect, switcher, status indicator', 'Model-independent backbone. First-class remote affordances. lib/builtin/welcome/src/welcome_view.dart: ''Connect to remote...'' beside ''Open folder...''; remote recents render with a host badge. lib/app.dart: project switcher accepts remote refs (open() branches on isRemote); the existing daemon-indicator slot (~:1172) becomes the connection indicator (connected / reconnecting / offline). registerStatusCommand (main.dart ~:245): surface host in clide status. Verify: make test (analyze + format + unit + widget + golden) for the UI; manual: connect indicator reflects drop/reconnect. Depends on Phase 2 (open scheme). UI dresses a working backend, so land last.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:26:45', '2026-06-10 13:26:45', NULL, '65c981fa45c4cb52fbe7df59d8046c78', 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 ('06FB3DQEMTDHF8SV27AKAB8JHW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Fork — execution layer (expand after Phase-0 spike decides footprint)', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:56', '2026-06-10 13:26:56', NULL, 'f5c3f4f28b4b4b6c4301ad0400182aee', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-10 13:27:36', NULL, 'e4644bb33acffeb4ac1cdd2ab7dd0942', 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 ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:41:17', NULL, '36eeacc93f291c6db429b1fa7a75c8c4', 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 ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 13:47:04', NULL, '564de005ff82db2137a7fc173038dd46', 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 ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 13:48:18', NULL, '33c0a8b0a26f1012dffc3dfa1e742463', 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 ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 13:53:23', NULL, 'b00b71e6716b515b5765abe808923132', 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 ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 13:53:28', NULL, 'bd4b80fac2b7afc66056ca93d3281736', 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 ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 13:53:55', NULL, '11e5f8e73fda443dfd532cbb20219b52', 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 ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 13:54:02', NULL, 'a8639457defad7f7e958c9fd0c567d97', 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 ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered — bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor — same path as `clide editor open <path>` — and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:55:30', NULL, 'd4022c088022495e2a0bda625a45c636', 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 ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 14:03:10', NULL, 'ee474dfe790eb6e72bd6a7381cd900e2', 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 ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 14:14:33', NULL, 'd615d07653f4916b50514993935b90da', 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 ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 14:29:07', NULL, '6fad665013ede83fb4e538bb73308aa6', 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 ('06FB0TNQM4QKBQY7FPCNX6N28R', 'task', '06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'detect and install tmux on first launch', 'On first launch, check if tmux is on PATH. If missing, prompt the user with platform-appropriate install instructions (apt, dnf, brew) or offer to install automatically. Claude pane requires tmux per D-41; without it the primary session cannot persist.
Obsolete: superseded by D-77/D-78. The Claude pane no longer requires tmux — it is driven over the stream-json stdio control protocol and persists via --resume (transcript files), not tmux. D-77 explicitly amends D-41 (tmux-for-persistence → --resume; tmux retained only for the general terminal). The ''primary session cannot persist without tmux'' premise is gone, so a first-launch tmux detect/install gate is unwarranted. Cancelling.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:27:28', '2026-06-10 14:41:37', NULL, '83a59c650c851a57c874e40024600520', 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 ('06FB0TNQM4QKBQY7FPCNX6N28R', 'task', '06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'detect and install tmux on first launch', 'On first launch, check if tmux is on PATH. If missing, prompt the user with platform-appropriate install instructions (apt, dnf, brew) or offer to install automatically. Claude pane requires tmux per D-41; without it the primary session cannot persist.
Obsolete: superseded by D-77/D-78. The Claude pane no longer requires tmux — it is driven over the stream-json stdio control protocol and persists via --resume (transcript files), not tmux. D-77 explicitly amends D-41 (tmux-for-persistence → --resume; tmux retained only for the general terminal). The ''primary session cannot persist without tmux'' premise is gone, so a first-launch tmux detect/install gate is unwarranted. Cancelling.', 'cancelled', 'medium', NULL, NULL, NULL, '2026-04-23 20:27:28', '2026-06-10 14:41:47', NULL, 'ff8be5b975f32e6f7eb64be712a1da0d', 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 ('06FB0TNQM4TQEYESK545T8F164', 'task', NULL, 'drag tab reordering in sidebar and context panel', 'Reorder tabs in the sidebar and context panel icon rails by dragging. Persist order to project settings.
Notes (2026-06-10):
1. Applies to BOTH rails — the left sidebar icon rail and the right context-bar icon rail. Reordering + persistence must work the same on each.
2. After ordering, the left-most (first) item in the rail is the one that opens by default.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 14:45:17', NULL, '0a4deb6d577554435b76af528b170224', 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 ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.', 'ready', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:46:51', NULL, '1637ba89d9bf5d1e81a42247e6cf46bd', 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 ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:53:03', NULL, '38f75d5cabd06dd1aa45701442eb9720', 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 ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.
Disposition (2026-06-10): mostly already done before pickup.
- #23 (errno constants): DONE prior. Magic numbers are centralized — errno values in lib/src/ipc/errno_mapping.dart (PosixErrno: eintr=4, ebadf=9, epipe=32, …), signals in lib/src/pty/ffi/libc.dart (sighup=1, sigwinch=28). native_pty.dart uses PosixErrno.* and libc.* throughout; no inline magic numbers remain. The ticket''s lib/src/pty/session.dart never existed at that path.
- #22 (logger): DONE prior. lib/src/ipc/server.dart imports the kernel Logger, holds a ''final Logger log'', and logs via log.error/warn/info(''ipc'', …). No stderr.writeln/print anywhere in lib/src/ipc, lib/src/pty, or lib/src/daemon. Folded in by the D-56 daemon dissolution + PTY FFI pivot.
- #26 (cmd correlation): the only live remnant — the catch-all ''dispatch threw'' log omitted the request cmd. Fixed: it now logs ''dispatch threw for "<cmd>"''. Internal logging only; no changelog.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:58:32', NULL, '97a7a5a0bb8841f3a586c8b559a42819', 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 ('06FB0TNQM734YZ060Q63H40EYG', 'task', NULL, 'Standardize errno constants + logger in PTY/IPC/daemon', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
**Errno constants (audit item #23):**
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
**Logger standardization (audit item #22, partial #26):**
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
- The Flutter-host process often consumes stderr, so log lines disappear silently.
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.
Disposition (2026-06-10): mostly already done before pickup.
- #23 (errno constants): DONE prior. Magic numbers are centralized — errno values in lib/src/ipc/errno_mapping.dart (PosixErrno: eintr=4, ebadf=9, epipe=32, …), signals in lib/src/pty/ffi/libc.dart (sighup=1, sigwinch=28). native_pty.dart uses PosixErrno.* and libc.* throughout; no inline magic numbers remain. The ticket''s lib/src/pty/session.dart never existed at that path.
- #22 (logger): DONE prior. lib/src/ipc/server.dart imports the kernel Logger, holds a ''final Logger log'', and logs via log.error/warn/info(''ipc'', …). No stderr.writeln/print anywhere in lib/src/ipc, lib/src/pty, or lib/src/daemon. Folded in by the D-56 daemon dissolution + PTY FFI pivot.
- #26 (cmd correlation): the only live remnant — the catch-all ''dispatch threw'' log omitted the request cmd. Fixed: it now logs ''dispatch threw for "<cmd>"''. Internal logging only; no changelog.', 'done', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 14:58:43', NULL, 'd7ba06c0e311a1945f8ccead0b132570', 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 ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:01:43', NULL, 'b853e77093d133d712104ef3a900762a', 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 ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:01:52', NULL, '21b160e1724fea9e39aec2884e50904b', 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 ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:01:54', NULL, 'e714ae4f4d0fc2f6a4f35e1c84b53ee9', 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 ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:02:14', NULL, 'a08e16020c92d6e38b94158c42b0e877', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-10 15:07:43', NULL, 'e4a9db7e1d1759ebd6a8ad97ab4e9aca', 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 ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).
Correction (2026-06-10): this ticket''s ''see Q-9'' reference is stale — Q-9 is ''Lua runtime vendoring'', unrelated. The search-everywhere / double-tap-modifier gap is now tracked by T-341.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:07:52', NULL, '4a85b3c69805401cba51e521e9ba7d5d', 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 ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).
Correction (2026-06-10): ''see Q-9'' is stale (Q-9 is Lua runtime vendoring). The double-Shift ''Search Everywhere'' chord is NOT expressible by the current matcher (bare/double modifiers unsupported) — tracked in T-341. This preset maps quick-open to Ctrl+Shift+N and the palette to Ctrl+Shift+A as the expressible IntelliJ equivalents.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:07:52', NULL, '067b925b777ecce5c0c25239193989f6', 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 ('06FB0TNQM5YG22EV7BFTX5RTPR', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'web-tree-sitter via JS interop for browser builds', 'Conditional import behind TreeSitterService: native impl uses dart:ffi to libtree-sitter.so, web impl uses dart:js_interop to web-tree-sitter (official emscripten build from tree-sitter org). Same grammar .wasm files on both platforms. Vendor web-tree-sitter .wasm + JS glue as Flutter web assets, pinned version, added to licenses.yaml.
Cancelled 2026-06-10 (backlog relevance sweep): contradicts the desktop-first guardrail (CLAUDE.md) - web is an explicit non-goal / happy-accident only. TreeSitterService is FFI-only and there is no shipped web product, so a web-tree-sitter dual-path is not wanted. Reopen only if web ever becomes a real target.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:33', '2026-06-10 15:13:50', NULL, 'dd1bb8fd534cfa37647528f4ed68da52', 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 ('06FB0TNQM50X0DX8XTVZEA5GN8', 'story', NULL, 'workspace trust prompt', 'Prompt before running extensions or loading project settings in untrusted repositories. Trust decision persisted per repo path. Untrusted mode disables third-party extensions and restricts IPC commands.
Cancelled 2026-06-10 (relevance sweep): premature. Third-party (Lua) extension loading is not shipped - ExtensionScanner.discover is test-only and the Lua runtime is a Tier-6 skeleton. Nothing to trust-gate yet; revisit at Tier 6 when external extension loading lands (the trust surface will likely be Lua sandboxing per D-19, not a per-repo prompt).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 15:13:52', NULL, '0edf2435dec17eca4a3fd8a605cc2afe', 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 ('06FB0TNQM6RDM2EKZX132GPV7M', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Add PRs tab to sidebar', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.
Cancelled 2026-06-10 (relevance sweep): spec''d in D-47 but unscoped, no extension exists, and the data path (git host API vs local metadata) is undecided. Closing to clear the backlog; file a fresh scoped story if a PRs surface is wanted.', 'backlog', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 15:13:52', NULL, '8fa9643461ef70f261fc895639d15811', 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 ('06FB0TNQM4W194B2421P2SF83R', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'CI reproducible build for libtree-sitter.so', 'BUILD.md at app/native/linux-x64/ has TODO checklist: build from pinned source SHA in CI, record SHA-256, cross-compile for macOS (aarch64, x86_64) and Windows (x86_64). Currently built on contributor machine.
Path fix (2026-06-10 sweep): ticket says app/native/linux-x64/ - the app/ prefix is stale (D-56 dissolved the two-package layout). Correct path is native/linux-x64/BUILD.md. Work remains valid: native/linux-x64/libtree-sitter.so is committed but there is still no CI build/cross-compile job.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:24', '2026-06-10 15:13:54', NULL, 'c52de8ef9eb80fae7cda60ffb23f8117', 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 ('06FB0TNQM48MDE8ZZ82VWNY994', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'migrate shipped extensions from builtin/ to extensions/ per D-46', 'D-46 defines the boundary: content extensions (editor, claude, claude-control, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) move from app/lib/builtin/ to app/lib/extensions/. Incremental — one at a time, each behind a working build. Extension contract must support bundled Dart extension as a first-class category.
Path fix (2026-06-10 sweep): app/lib/builtin/ -> lib/builtin/ (app/ prefix stale per D-56). D-46 still confirmed/active. lib/extensions/ does not exist yet and the shipped extensions (editor, claude, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) are still under lib/builtin/. Migration unstarted, still valid.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:45', '2026-06-10 15:13:56', NULL, '8b66e304cc2695c4850917dad1e53de1', 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 ('06FB0TNQM6T6580D8ABDVTMNZW', 'epic', NULL, 'Tier 6 — extension API, settings, theming, distributable builds', 'Tier 6 of the build plan: the things that make clide a real product instead of a working prototype.
**Extension API (third-party Lua):**
- The Lua runtime supporter tool (D-19) lands as a peer of pql/ptyc.
- Manifest schema, capability gating, sandboxed FS/IPC access.
- Same TabContribution / CommandContribution / etc. surface as built-in Dart extensions (D-15).
- Marketplace / distribution story is OUT OF SCOPE for Tier 6 — local-install only.
**Settings UI (`builtin.settings-ui`):**
- Schema-driven settings panel reading from the kernel SettingsStore.
- Render strategy: form fields keyed off the schema each subsystem registers.
- Edits write back to `.clide/settings.yaml`.
**Theming UI (`builtin.theme-picker` extends):**
- Live preview of the four bundled themes (D-44).
- Custom theme: import YAML, validate against schema, register at runtime.
- Per-component override surface (long horizon).
**Distributable builds:**
- AppImage / Flatpak for Linux, .dmg for macOS — see T-46.
- Self-update mechanism — see T-47.
- License manifest auto-regen as part of the release build.
Big epic — children land incrementally. Most concrete child tickets already exist; this is the umbrella.
Status note (2026-06-10 sweep): mixed completion. theme-picker is substantially implemented; settings-ui is a stub; the Lua runtime is skeleton-only (lib/lua/); distributable builds (T-46/T-47) remain deferred Tier-6 work. Epic stays open as the umbrella.', 'backlog', 'medium', NULL, NULL, 'D-15', '2026-04-22 11:45:41', '2026-06-10 15:13:57', NULL, 'bcccbd9609f6aba3fcfd3117f61af377', 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 ('06FB0TNQM66FTCTWHH9AQTNFKR', 'story', NULL, 'system tray and dock persistence', 'Minimize to system tray on Linux (AppIndicator) or Dock on macOS. Reopening from tray restores the window without cold boot. tmux sessions stay alive in background regardless.
Scope split (2026-06-10 sweep): the session-persistence half is effectively done - tmux keeps Claude/terminal sessions alive across restart (D-41). The OS-tray/AppIndicator + dock half is a stub only (lib/kernel/src/tray.dart - TrayRegistry has no platform-channel wiring) and is Tier-6+. Remaining work = the tray integration.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 15:13:59', NULL, 'ab5f60da82d9e23bf6104099b2fa9af0', 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 ('06FB0TNQM67JSC5RKS6M9182KG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Add images tab to context panel', 'Spec lists images as a right-panel section. No extension exists yet.
Relevance note (2026-06-10 sweep): likely superseded. The image card + full-screen lightbox shipped (T-249/T-252) and the canvas epic (T-317, D-91) folds image display into the unified drawing-card renderer rather than a separate context-panel tab. Confirm whether a distinct images rail section is still wanted; otherwise close in favor of the canvas path.', 'backlog', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 15:14:01', NULL, 'f81afd3f2563491ccd7325ced11e5e25', 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 ('06FB0TNQM7P6Q0DG4RG4CBHFJG', 'story', NULL, 'review Semantics widget coverage and UI testability', 'Audit all interactive widgets for Semantics coverage (labels, roles, states). Verify flutter test can locate and interact with every panel, button, and input via find.bySemanticsLabel. Run the existing a11y test suite and document gaps. Target: every user-facing action is testable without widget keys.
Reframe (2026-06-10 sweep): the original ''audit Semantics coverage'' framing is stale - test/a11y/ (semantic_coverage, contrast, keyboard_traversal, i18n) is now a mature per-PR gate per D-20. Re-scope to forward work: ratchet the semantic-coverage floor and deepen per-extension Semantics assertions, rather than a one-time review.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:14:02', NULL, '6d8c5bcd384a18ffa8f34f642c4f680e', 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 ('06FB0TNQM79CNBXJ2S3CFQR7VM', 'task', NULL, 'Misc PTY/IPC/git polish (audit medium-priority items)', 'Catch-all for the medium-priority items from the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md) that didn`t earn dedicated tickets:
- **#17** — `files.read` `readAsStringSync` is unguarded; UTF-8 errors / permissions / mid-read deletion become 500-style dispatch errors. Wrap in try/catch and emit a clean `IpcResponse.err`.
- **#19** — `PtySession.close` swallows the 500ms timeout silently (`onTimeout: () {}`). Log when the timeout fires so we know SIGKILL was needed.
- **#20** — Reader isolate treats every negative `read()` return that isn`t EINTR as EOF. Distinguish EBADF/EIO (real EOF) from transient EAGAIN (recoverable) and log the latter.
- **#21** — `scm_rights.dart` reads cmsg-data fd without verifying `dataOffset + 4 <= msgControllen`. Bounds check before deref so a malformed peer can`t feed garbage as an fd.
- **#25** — `_gitError` in `lib/src/daemon/git_commands.dart` always reports `tool_error`; push rejections / merge conflicts should map to `IpcExitCode.conflict` when stderr matches known patterns.
- **#27** — `pane.spawn` returns `ok` even when `registry.write(id, bytes)` returned `n == -1`. Distinguish the failure.
- **#28** — `IpcResponse.fromJson` throws `TypeError` on a malformed peer response missing `error`. Graceful degrade.
- **#29** — PATH resolution in `native_pty.dart` uses the first existing match without `X_OK` check; non-executable files shadow valid binaries further along PATH.
Land each as a small focused commit; ticket closes when all items above are merged.
Item status (2026-06-10 sweep): from the T-18 audit, #16 (git error kinds) landed via T-79 and #22 (logging) via T-80. #21 (scm_rights.dart bounds check) is OBSOLETE - fd-passing/recvmsg was removed, the file no longer exists; drop it. Spot-checked still-open: #17 files.read unguarded readAsStringSync (files_commands.dart), #28 IpcResponse.fromJson TypeError (envelope.dart), #29 PATH X_OK check (native_pty.dart). ~7 items remain.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-05 12:58:59', '2026-06-10 15:14:04', NULL, 'd98147f71017f0d6b3b8e0688c2760c9', 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 ('06FB0TNQM5YG22EV7BFTX5RTPR', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'web-tree-sitter via JS interop for browser builds', 'Conditional import behind TreeSitterService: native impl uses dart:ffi to libtree-sitter.so, web impl uses dart:js_interop to web-tree-sitter (official emscripten build from tree-sitter org). Same grammar .wasm files on both platforms. Vendor web-tree-sitter .wasm + JS glue as Flutter web assets, pinned version, added to licenses.yaml.
Cancelled 2026-06-10 (backlog relevance sweep): contradicts the desktop-first guardrail (CLAUDE.md) - web is an explicit non-goal / happy-accident only. TreeSitterService is FFI-only and there is no shipped web product, so a web-tree-sitter dual-path is not wanted. Reopen only if web ever becomes a real target.', 'cancelled', 'medium', NULL, NULL, NULL, '2026-04-22 20:14:33', '2026-06-10 15:14:17', NULL, '366c0ac563b3f001d6e783dda9cc60f2', 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 ('06FB0TNQM50X0DX8XTVZEA5GN8', 'story', NULL, 'workspace trust prompt', 'Prompt before running extensions or loading project settings in untrusted repositories. Trust decision persisted per repo path. Untrusted mode disables third-party extensions and restricts IPC commands.
Cancelled 2026-06-10 (relevance sweep): premature. Third-party (Lua) extension loading is not shipped - ExtensionScanner.discover is test-only and the Lua runtime is a Tier-6 skeleton. Nothing to trust-gate yet; revisit at Tier 6 when external extension loading lands (the trust surface will likely be Lua sandboxing per D-19, not a per-repo prompt).', 'cancelled', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-10 15:14:17', NULL, '34d12878273efad542a07a4e96eef3ac', 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 ('06FB0TNQM6RDM2EKZX132GPV7M', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Add PRs tab to sidebar', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.
Cancelled 2026-06-10 (relevance sweep): spec''d in D-47 but unscoped, no extension exists, and the data path (git host API vs local metadata) is undecided. Closing to clear the backlog; file a fresh scoped story if a PRs surface is wanted.', 'cancelled', 'low', NULL, NULL, 'D-47', '2026-04-22 21:03:40', '2026-06-10 15:14:17', NULL, '2d35befb6eebdc0a5778c26887622e85', 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 ('06FB0TNQM42M9RK4399B4F4WSG', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).
Correction (2026-06-10): this ticket''s ''see Q-9'' reference is stale — Q-9 is ''Lua runtime vendoring'', unrelated. The search-everywhere / double-tap-modifier gap is now tracked by T-341.', 'done', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:17:59', NULL, 'f4c92b7fb7bbf4e1a2afb9b02f223822', 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 ('06FB0TNQM60QRRNEEWG84VWKXC', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).
Correction (2026-06-10): ''see Q-9'' is stale (Q-9 is Lua runtime vendoring). The double-Shift ''Search Everywhere'' chord is NOT expressible by the current matcher (bare/double modifiers unsupported) — tracked in T-341. This preset maps quick-open to Ctrl+Shift+N and the palette to Ctrl+Shift+A as the expressible IntelliJ equivalents.', 'done', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-10 15:17:59', NULL, '58c6bc49c1ec261dfffc3d5d60fc44a5', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 15:26:33', NULL, '49ed852675b4f44fcef84d079146f207', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 15:27:01', NULL, 'd82afb6688c82c068d6020a279652736', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 15:28:52', NULL, '05bc2daa013de8cf163cdd54c6014f07', 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 ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 15:54:09', NULL, '3e7c03bbb536a4c2d45f770abfee8b13', 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 ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 15:54:23', NULL, 'f5614424c00196aabab868db75483bf2', 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 ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 16:02:55', NULL, '83c5d82ad411e2827733928cdb81c6e0', 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 ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:06:08', NULL, 'c858a9b7c474697611c3ff8240c61a2d', 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 ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)
Design revision (2026-06-10, supersedes the chip set/order above): FIVE chips, one per pql type — no story+task grouping. Ordered LARGE→SMALL left to right: Initiative, Epic, Story, Task, Bug. Each maps 1:1 to its pql type (initiative/epic/story/task/bug) with its TicketTypeColors dot+border (initiative #C792EA, epic #78A0F8, story #7DD3A8, task #9AA0AA grey, bug #E87D7D). All five ON by default. Toggle/solo(double-click)/last-off-reset behaviour unchanged. Wireframe updated + approved.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 16:06:25', NULL, '93554cd5afe5bd881b12bef2892e25b8', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 16:09:21', NULL, 'd9e663378cc6deec9bc85f95f5552db8', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 16:09:26', NULL, 'fc45344f2d71e9b71573100825c2bce7', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', 'epic', NULL, 'UI tweaks & fixes', 'Ongoing umbrella for small, standalone UI polish, cosmetic tweaks, and visual/interaction bug fixes that don''t belong to a feature epic — color/token corrections, control placement, status surfaces, micro-interactions, and the wireframes that frame them. Children are independently shippable; the epic stays open as a rolling home for this class of work.
**PERMANENT — never close.** This is a standing rolling tracker for loose UI/UX work and bugs, not a deliverable epic. It stays open indefinitely; only its children are completed/closed. Do not mark T-276 done even when all current children are closed — new tweaks/fixes get filed here on an ongoing basis.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:46:23', '2026-06-10 16:09:57', NULL, 'a1ab5ae76714ede6d6a473db9b5d8aa9', 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 ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:10:20', NULL, '140190d844d98929ecc83e30cdcc0745', 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 ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:10:22', NULL, 'f02a03592e970322a82bf1be7002b7fa', 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 ('06FB4FDREHRYRR7B9ER72KCQKC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket panel: per-type filter chips (Bug / Ticket / Epic / Initiative)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
## Behaviour
- Single-click a chip → toggle that type in/out of the list.
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
- Tooltip per chip: "Click to toggle · double-click to isolate".
## Type mapping (pql → chip)
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
- Bug → bug
- Ticket → story + task (leaf work items)
- Epic → epic
- Initiative → initiative
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
## Filtering
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
## Implementation notes
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
## Wireframe
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)
Design revision (2026-06-10, supersedes the chip set/order above): FIVE chips, one per pql type — no story+task grouping. Ordered LARGE→SMALL left to right: Initiative, Epic, Story, Task, Bug. Each maps 1:1 to its pql type (initiative/epic/story/task/bug) with its TicketTypeColors dot+border (initiative #C792EA, epic #78A0F8, story #7DD3A8, task #9AA0AA grey, bug #E87D7D). All five ON by default. Toggle/solo(double-click)/last-off-reset behaviour unchanged. Wireframe updated + approved.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:54:09', '2026-06-10 16:12:15', NULL, '3dd40475245455459d9f09d26ef34316', 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 ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:13:50', NULL, '10a7749a97c64f223054a8d582eead3d', 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 ('06FB4J5E6W983P1S7BE0FDPSMM', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Conversation card font sizes inconsistent: ClideCollapserCard vs ConversationCard', 'Two collapsible-card primitives sit adjacent in the Claude conversation stream but use different font-size tokens for the same visual roles, so labels/summaries are visibly 1-2px off between neighbouring cards.
- ClideCollapserCard (lib/widgets/src/clide_collapser_card.dart) — the ''Activity'' / agent-run / edit-run cards: header label, collapsedSummary, and counter all use clideFontCaption (14).
- ConversationCard (lib/builtin/claude/src/conversation_card.dart) — tool-use / result / ''denied'' cards: header label uses clideFontSmall (12), collapsedSummary uses clideFontMeta (13).
Result: an ''Activity Bash …'' collapser and a neighbouring ''Bash · denied'' tool card render their label + collapsed summary at different sizes in the same list. Spotted in a screenshot of three stacked cards (collapser Bash, denied tool result, collapser Read).
Fix: pick one set of tokens for the shared header/label/summary role across both primitives (likely align ConversationCard''s label to clideFontCaption and standardise the collapsed-summary size), or extract a shared card-header style. Tokens: clideFontCaption=14, clideFontMeta=13, clideFontSmall=12 (lib/widgets/src/typography.dart).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 16:06:08', '2026-06-10 16:15:38', NULL, 'e5e9f526180b6e48628629c2c3c5138b', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-10 16:21:55', NULL, 'dcbfa661a4a0238a2578ee8ed800444b', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-10 16:22:24', NULL, '1ecfc619afc1c684d10ccce3f2ad2a72', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-10 16:22:28', NULL, 'd87a50140fc2477aad77f2bf10c64da9', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-10 16:22:37', NULL, '031ad585f4bea545d6efa0ead8c3830e', 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 ('06FB4RD1DDSYM4J7WYEGTXARB4', 'task', NULL, 'Make the coverage harness concurrency-safe (per-run paths)', 'ci/test.sh --coverage runs two flutter test --coverage passes that both write the fixed coverage/lcov.info, copies it to coverage/lcov.parallel.info, merges, then rm -f''s the intermediate. Any concurrent flutter test --coverage (a second push gate, or a ''make test'' during a push) races on those shared paths — the second run''s rm -f deletes lcov.parallel.info mid-merge, crashing merge_lcov.py with FileNotFoundError (observed during a push, 2026-06-10).
Fix: give each run its own coverage output via flutter test --coverage-path <tmp>, with a per-run mktemp dir for the two passes + the merge; only the final merged result lands in coverage/lcov.info via an atomic rename within coverage/. No shared intermediate paths, so concurrent runs can''t corrupt each other.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 16:33:23', '2026-06-10 16:33:23', NULL, '9b0de784a50a9ba162dc46fc477ca6b5', 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 ('06FB4RD1DDSYM4J7WYEGTXARB4', 'task', NULL, 'Make the coverage harness concurrency-safe (per-run paths)', 'ci/test.sh --coverage runs two flutter test --coverage passes that both write the fixed coverage/lcov.info, copies it to coverage/lcov.parallel.info, merges, then rm -f''s the intermediate. Any concurrent flutter test --coverage (a second push gate, or a ''make test'' during a push) races on those shared paths — the second run''s rm -f deletes lcov.parallel.info mid-merge, crashing merge_lcov.py with FileNotFoundError (observed during a push, 2026-06-10).
Fix: give each run its own coverage output via flutter test --coverage-path <tmp>, with a per-run mktemp dir for the two passes + the merge; only the final merged result lands in coverage/lcov.info via an atomic rename within coverage/. No shared intermediate paths, so concurrent runs can''t corrupt each other.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 16:33:23', '2026-06-10 16:34:51', NULL, '75938b68f10edf857e25100141d08544', 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 ('06FB4VG3N3YJSV8G7M1HFSYW2W', 'task', NULL, 'Profile and speed up the push-check gate', 'The push gate (make push-check) is dominated by ''flutter test --coverage'' (coverage instrumentation ~doubles flutter-test wall-time vs the ~20s no-coverage ''make test''). Recent slowness was traced to concurrent-session contention (fixed by T-345), not suite bloat — but there is real speedup headroom.
Findings (2026-06-10):
- 245 test files; the 39 Flutter-free core files (test/ipc, daemon, git, panes, files, editor, pql) run TWICE in the gate: under flutter test --coverage (for the coverage floor) AND under dart test (test-core, the Flutter-free contract guard). Each serves a purpose but it is the clearest redundancy.
- 33 test files use pumpAndSettle (ci/test.sh''s own comment warns against it; prefer the pumpAsync helper) — a common wall-time/hang sink.
- 47 ''Duration(seconds:)'' occurrences in tests; some are legit timeouts, some are real-time waits that add wall-clock.
Scope:
1. Profile: one timed run (JSON reporter / --reporter) to rank the ~20 slowest tests; the pumpAndSettle + Duration(seconds:) files are prime suspects.
2. Fix the genuinely slow ones (bounded pumps, fake-async, no real sleeps) per feedback_tests_run_light + feedback_runasync_for_io.
3. Investigate collapsing the core double-run — collect coverage from the dart test core pass and drop those 39 files from the flutter --coverage pass — only if the profile shows that slice is large enough to justify the refactor.
Do NOT cut tests for speed: 245 files for a public tool with a 95% floor is comprehensive, not over-tested. Make slow tests fast instead.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 16:46:54', '2026-06-10 16:46:54', NULL, '481a097650e0b1badea0b24cdf48afda', 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 ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'bug', NULL, 'pql pane fails in desktop-launched clide: PATH not expanded on Linux', 'The pql sidebar pane (and any PATH-resolved tool) fails when clide is launched from a desktop launcher rather than a terminal. lib/kernel/src/toolchain_paths.dart _expandedPath() augments PATH with ~/.local/bin + /usr/local/bin etc. ONLY on macOS (line: ''if (!Platform.isMacOS) return base;''). On Linux it uses the raw inherited PATH.
A GUI/desktop-launched app inherits a minimal PATH (e.g. /usr/bin:/bin) with no ~/.local/bin, so _findOnPath(''pql'') returns null, clide falls back to spawning the literal ''pql'', and Process.start fails with ENOENT — the pql pane errors. pql is installed to ~/.local/bin. Reproduced: ''env -i PATH=/usr/bin:/bin command -v pql'' → not found. Works under ''make run'' only because the terminal PATH includes ~/.local/bin.
Fix: extend _expandedPath() to also augment on Linux — prepend ~/.local/bin and /usr/local/bin (keep the homebrew dirs macOS-only). Affects pql, tmux, and PATH-resolved git alike. User running the installed build needs a rebuild+reinstall after the fix; immediate workaround is launching clide from a terminal.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '4a647a5c33b044df6ace163651fc8e93', 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 ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'bug', NULL, 'pql pane fails in desktop-launched clide: PATH not expanded on Linux', 'The pql sidebar pane (and any PATH-resolved tool) fails when clide is launched from a desktop launcher rather than a terminal. lib/kernel/src/toolchain_paths.dart _expandedPath() augments PATH with ~/.local/bin + /usr/local/bin etc. ONLY on macOS (line: ''if (!Platform.isMacOS) return base;''). On Linux it uses the raw inherited PATH.
A GUI/desktop-launched app inherits a minimal PATH (e.g. /usr/bin:/bin) with no ~/.local/bin, so _findOnPath(''pql'') returns null, clide falls back to spawning the literal ''pql'', and Process.start fails with ENOENT — the pql pane errors. pql is installed to ~/.local/bin. Reproduced: ''env -i PATH=/usr/bin:/bin command -v pql'' → not found. Works under ''make run'' only because the terminal PATH includes ~/.local/bin.
Fix: extend _expandedPath() to also augment on Linux — prepend ~/.local/bin and /usr/local/bin (keep the homebrew dirs macOS-only). Affects pql, tmux, and PATH-resolved git alike. User running the installed build needs a rebuild+reinstall after the fix; immediate workaround is launching clide from a terminal.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 16:55:10', '2026-06-10 16:57:07', NULL, '98d93fef85789907f6bf6efc52a3fcdb', 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 ('06FB50YE6S6YWNP2ZSFWES9B2W', 'task', NULL, 'Pre-push gate: skip the test suite for docs/changelog/pql/meta-only pushes', 'The .githooks/pre-push hook runs ''make push-check'' unconditionally — the full ~2min coverage + test-core suite even when a push touches only documentation, the changelog, pql ticket data, or other meta files that cannot affect the Flutter/Dart build or tests.
Make the hook path-aware: diff the commits being pushed (remote_sha..local_sha from the hook''s stdin). If every changed file is under an allowlist that can''t affect tests — docs/, governance/, .pql/, .claude/, CHANGELOG.md, README.md, CLAUDE.md — run only the instant gates (decisions-validate + changelog-gate) and skip test-coverage/coverage-gate/test-core. Any code-ish path (lib/, test/, assets/, pubspec.*, native/, ci/, .githooks/, Makefile, platform dirs) → full gate. Fail safe: unknown/new-branch states run the full gate.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, 'edb52a1ac5c6a0e01ebaaf5ceff5e96c', 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 ('06FB50YE6S6YWNP2ZSFWES9B2W', 'task', NULL, 'Pre-push gate: skip the test suite for docs/changelog/pql/meta-only pushes', 'The .githooks/pre-push hook runs ''make push-check'' unconditionally — the full ~2min coverage + test-core suite even when a push touches only documentation, the changelog, pql ticket data, or other meta files that cannot affect the Flutter/Dart build or tests.
Make the hook path-aware: diff the commits being pushed (remote_sha..local_sha from the hook''s stdin). If every changed file is under an allowlist that can''t affect tests — docs/, governance/, .pql/, .claude/, CHANGELOG.md, README.md, CLAUDE.md — run only the instant gates (decisions-validate + changelog-gate) and skip test-coverage/coverage-gate/test-core. Any code-ish path (lib/, test/, assets/, pubspec.*, native/, ci/, .githooks/, Makefile, platform dirs) → full gate. Fail safe: unknown/new-branch states run the full gate.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 17:10:42', '2026-06-10 17:13:53', NULL, 'f846b01b35bda1b8e247f360c46d472f', 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 ('06FB58X0TFJ02YTMVPD0D9Q838', 'bug', NULL, 'Linux build silently drops frameless chrome when wayland-client missing', 'linux/runner/CMakeLists.txt does pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client) WITHOUT REQUIRED, then only links it + defines HAS_WAYLAND_CLIENT inside if(WAYLAND_CLIENT_FOUND). The entire frameless custom-chrome path (D-057, KDE server-decoration protocol in clide_app.cc) is #ifdef HAS_WAYLAND_CLIENT.
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, 'a60bee185c5bae1a8bf8bca98303883f', 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 ('06FB58X0TFJ02YTMVPD0D9Q838', 'bug', NULL, 'Linux build silently drops frameless chrome when wayland-client missing', 'linux/runner/CMakeLists.txt does pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client) WITHOUT REQUIRED, then only links it + defines HAS_WAYLAND_CLIENT inside if(WAYLAND_CLIENT_FOUND). The entire frameless custom-chrome path (D-057, KDE server-decoration protocol in clide_app.cc) is #ifdef HAS_WAYLAND_CLIENT.
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 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 ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'd03c864f5e64284f3582e59556a4ab4f', 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 ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:20:28', NULL, 'd48c86ad83f3da7cd4f8c9308c852448', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, 'ec216013c0bb79f1f11a87979c9fb886', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'review', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:24:41', NULL, '0d06f87ff4d71e2ee4e4821968b5e3df', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:27:58', NULL, 'e61eeca3f6ccf42a334867c757fda759', 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 ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, 'a6d1821c06dbccb30c9f7e777aef7ef5', 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 ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, 'd5de0d7f9b1c365f7a35c6d0aefa178e', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:54', NULL, 'd81c595ab22359cc6c75d490d17d8c5d', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:58', NULL, '352f3ceb8714f14bd12515a9b4542794', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-11 10:47:15', NULL, 'b277bfbe5ed8986df00e1e031ad34ad6', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 10:49:25', NULL, '95cc390bd8d38f7a3568cf367864d1ae', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 10:49:33', NULL, '96c4174428163a4998490d15c8463149', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE — Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-11 11:27:00', NULL, 'f6605c8b126930cccf4be2d74efa6c4d', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 11:27:07', NULL, 'c1a9ecdf23d66e94b96feb08d6f93d60', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 12:35:27', NULL, '8bf60e7ca53d32f5ccfabdbfea4dd84d', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 12:35:27', NULL, 'a1faa7af007b8cb1952fb7595e720c4a', 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 ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 12:57:24', NULL, 'd822618eb175daca9547b05cb07096d4', 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 ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-11 13:01:55', NULL, '02b5cb1f954c1733edefaee9a8f0a03d', 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 ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.
─────────────────────────────────────────────
REFINED 2026-06-11
## Current state (grounding)
- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`,
`clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide),
generated from pubspec by `make gen-build-info`. This is the "installed version".
- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client
at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app`
+ `~/.local/bin/clide`. Windows: not yet shipped.
- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would
be the FIRST one — so this is a policy-sensitive feature, not just plumbing.
- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An
in-place update that restarts the app does NOT lose sessions — they live in tmux,
outside the bundle.
## HARD CONSTRAINTS (non-negotiable)
- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user
action." This is STRICTER than this ticket''s original "opt-in or gated behind a
setting" wording. A background/startup check — even one a setting enabled — runs
"without user action" at that launch and conflicts with D-64. RESOLUTION: the
version check must be **explicitly user-initiated every time** (a command-palette
"Check for updates…" action / an About-screen button). If we ever want a
startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t
assume.
- **POLICY.md §"no network on the default launch path":** opening the app, a file,
or typing must never trigger the fetch. The update check + download are explicit
user actions, so they''re allowed — but must meet the §"grudging allowance"
criteria: clear error on failure (not silent), cached result, app fully functional
if the fetch fails.
## BLOCKING PREREQUISITE (likely its own ticket under T-46)
There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0)
despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary
artifacts. Self-update is meaningless without:
1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag).
2. CI that builds the per-platform bundles and publishes them as GitHub Releases.
3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is
determined by the SIGNED release artifact"). An unsigned/unverified download
would break the trust model the update is supposed to preserve.
4. A machine-readable "latest version" source — the GitHub Releases API
(`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a
committed `latest.json` manifest is the alternative.
RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI
build + signed GitHub Releases + version manifest" and make T-47 depend on it.
## DECISIONS TO MAKE (surface before building)
1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API —
no extra infra, origin is already GitHub.)
2. Signature scheme + verification: minisign/age/cosign? Where does the public key
live (vendored in-repo, per POLICY.md provenance)?
3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full
for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts.
4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the
`~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement +
notarization/quarantine handling is harder. Windows out of scope until it ships.
5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good.
A system-wide install would; declare user-local only for v1.
## PROPOSED SCOPE / PHASES (each independently shippable)
P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases.
P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of
the latest release, semver-compare against `clideVersion`, non-intrusive ToastService
notification ("clide X.Y.Z is available") with a "What''s changed" link to the release
notes. No download yet. Clear error toast on network failure. Fully covers the D-64 /
POLICY-compliant "notify" half of the story.
P2: download + signature/checksum verify into a staging dir; show progress; verify before
touching the install.
P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app;
tmux sessions survive (D-41). Confirm-before-apply.
P4 (optional): macOS apply path (.app swap + quarantine), delta updates.
## ACCEPTANCE (for the full story; refine per-phase ticket)
- No network call on any default launch path (verified — grep + a test that boot makes
no outbound connection).
- "Check for updates" only runs on explicit user action; failure surfaces a clear
toast, never a silent hang or degraded launch.
- A downloaded update is signature+checksum verified before it can replace the install;
verification failure aborts with the old version intact.
- Applying an update and relaunching preserves running Claude/tmux sessions.
- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined).
## REFERENCES
POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home);
D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source);
`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern
(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target
(per-platform layout); parent epic T-46 (cross-platform installer).', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-11 13:30:03', NULL, '73a6def84544128c6212852d22c58e9f', 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 ('06FB0TNQM7CEKQCMZAV751402G', 'story', '06FB0TNQM79H4MPEWW2TWQ89D0', 'clide self-update mechanism', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.
─────────────────────────────────────────────
REFINED 2026-06-11
## Current state (grounding)
- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`,
`clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide),
generated from pubspec by `make gen-build-info`. This is the "installed version".
- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client
at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app`
+ `~/.local/bin/clide`. Windows: not yet shipped.
- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would
be the FIRST one — so this is a policy-sensitive feature, not just plumbing.
- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An
in-place update that restarts the app does NOT lose sessions — they live in tmux,
outside the bundle.
## HARD CONSTRAINTS (non-negotiable)
- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user
action." This is STRICTER than this ticket''s original "opt-in or gated behind a
setting" wording. A background/startup check — even one a setting enabled — runs
"without user action" at that launch and conflicts with D-64. RESOLUTION: the
version check must be **explicitly user-initiated every time** (a command-palette
"Check for updates…" action / an About-screen button). If we ever want a
startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t
assume.
- **POLICY.md §"no network on the default launch path":** opening the app, a file,
or typing must never trigger the fetch. The update check + download are explicit
user actions, so they''re allowed — but must meet the §"grudging allowance"
criteria: clear error on failure (not silent), cached result, app fully functional
if the fetch fails.
## BLOCKING PREREQUISITE (likely its own ticket under T-46)
There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0)
despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary
artifacts. Self-update is meaningless without:
1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag).
2. CI that builds the per-platform bundles and publishes them as GitHub Releases.
3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is
determined by the SIGNED release artifact"). An unsigned/unverified download
would break the trust model the update is supposed to preserve.
4. A machine-readable "latest version" source — the GitHub Releases API
(`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a
committed `latest.json` manifest is the alternative.
RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI
build + signed GitHub Releases + version manifest" and make T-47 depend on it.
## DECISIONS TO MAKE (surface before building)
1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API —
no extra infra, origin is already GitHub.)
2. Signature scheme + verification: minisign/age/cosign? Where does the public key
live (vendored in-repo, per POLICY.md provenance)?
3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full
for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts.
4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the
`~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement +
notarization/quarantine handling is harder. Windows out of scope until it ships.
5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good.
A system-wide install would; declare user-local only for v1.
## PROPOSED SCOPE / PHASES (each independently shippable)
P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases.
P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of
the latest release, semver-compare against `clideVersion`, non-intrusive ToastService
notification ("clide X.Y.Z is available") with a "What''s changed" link to the release
notes. No download yet. Clear error toast on network failure. Fully covers the D-64 /
POLICY-compliant "notify" half of the story.
P2: download + signature/checksum verify into a staging dir; show progress; verify before
touching the install.
P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app;
tmux sessions survive (D-41). Confirm-before-apply.
P4 (optional): macOS apply path (.app swap + quarantine), delta updates.
## ACCEPTANCE (for the full story; refine per-phase ticket)
- No network call on any default launch path (verified — grep + a test that boot makes
no outbound connection).
- "Check for updates" only runs on explicit user action; failure surfaces a clear
toast, never a silent hang or degraded launch.
- A downloaded update is signature+checksum verified before it can replace the install;
verification failure aborts with the old version intact.
- Applying an update and relaunching preserves running Claude/tmux sessions.
- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined).
## REFERENCES
POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home);
D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source);
`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern
(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target
(per-platform layout); parent epic T-46 (cross-platform installer).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-23 20:28:43', '2026-06-11 13:30:47', NULL, '28d5684adc9127f1306bddaaff8d1f77', 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 ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'initiative', NULL, 'Internalize pql: bundle the binary and own workspace onboarding', NULL, 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:51', '2026-06-11 13:36:51', NULL, '28d6eb7a1b8658ae5db18328446cbf69', 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 ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Bundle and resolve pql as a vendored pinned binary', NULL, 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:55', '2026-06-11 13:36:55', NULL, 'cd19e7f43063ceaa61e8cb0dc68a6268', 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 ('06FBDSM0PRGYR61R0NWYAT9VDC', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Single-directory workspace footprint: relocate IDE state to user scope', NULL, 'backlog', 'medium', NULL, NULL, 'D-93', '2026-06-11 13:37:00', '2026-06-11 13:37:00', NULL, 'e81d105b64347dfb59a81a28cbc1c594', 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 ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Workspace onboarding flow and read-mode degrade', NULL, 'backlog', 'medium', NULL, NULL, 'D-95', '2026-06-11 13:37:19', '2026-06-11 13:37:19', NULL, '91b0ac3a4bb69c24f7c9eef9034f7ca3', 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 ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'task', '06FBDSPECQ0FPKB9SYTD7KZSBM', 'Declared extension modes (manifest) + classify builtins as read-mode-safe', NULL, 'backlog', 'medium', NULL, NULL, 'D-94', '2026-06-11 13:37:25', '2026-06-11 13:37:25', NULL, '1aa454e0f1de6710b57c7fd56fd5194a', 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 ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'initiative', NULL, 'Internalize pql: bundle the binary and own workspace onboarding', 'Eliminate the out-of-repo pql dependency friction: clide ships its own pinned pql and owns first-open workspace setup. Spans four decisions — D-92 (bundle pql), D-93 (zero clide dirs in-repo), D-94 (workspace modes), D-95 (onboarding + read-mode). Outcome: a fresh clone/install of clide works with no separate pql install or version coordination; the repo''s only tool dirs are .git/ and .pql/. Three epics: T-355 bundle, T-356 footprint, T-357 onboarding.', 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:51', '2026-06-11 13:37:57', NULL, 'fef23cd1e616df992b40126e76cd4377', 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 ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Bundle and resolve pql as a vendored pinned binary', 'Vendor a version-pinned pql binary per shipped platform under native/<platform>/, following the dugite pattern (D-59/D-63): BUILD.md provenance (upstream commit SHA, build command, toolchain, sha256), assets/licenses.yaml entry (D-42/D-65). Add a bundled-first resolver mirroring _resolveDugiteGit() in lib/kernel/src/toolchain_paths.dart: CLIDE_PQL_BIN env override -> binary next to the executable -> system pql on PATH (currently pql is PATH-only via _findOnPath at line 88). SECURITY: resolve against the install dir only, never workspace-relative — a planted ./native/pql is a code-exec vector (the T-98 dugite lesson). Bundled copy must not self-update. Add a soft version-floor check that surfaces an out-of-date override/PATH pql in the Problems panel. Switch CI to run the in-tree binary instead of assuming pql on the runner.', 'backlog', 'medium', NULL, NULL, 'D-92', '2026-06-11 13:36:55', '2026-06-11 13:38:05', NULL, '0df8e53ac69113dab9baa174c0f90ff6', 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 ('06FBDSM0PRGYR61R0NWYAT9VDC', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Single-directory workspace footprint: relocate IDE state to user scope', 'Move clide''s project-scoped state out of the in-repo .clide/ dir into user scope, keyed by a workspace-path hash (reuse the FNV-1a convention from D-70''s IPC socket path). Affected today: SettingsStore project file (_projectFile -> .clide/settings.yaml in lib/kernel/src/settings.dart) and theme_persistence.dart (project.theme). Provide a one-time migration that relocates an existing .clide/settings.yaml to user scope and removes the dir. Drop .clide/ from the gitignore-at-install set (only .pql/ remains). If shared/committed clide config is ever needed, it goes as clide-owned keys in .pql/config.yaml, not a new dir. Note: the open extension-DB question (governance/questions/process.md Q on .clide/clide.db) now assumes a user-scope DB. Accept D-70''s trade-off: moving/renaming a repo re-keys it and resets personal layout.', 'backlog', 'medium', NULL, NULL, 'D-93', '2026-06-11 13:37:00', '2026-06-11 13:38:13', NULL, 'baf3e0de9a0b61b32d37638267445e1f', 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 ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'epic', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Workspace onboarding flow and read-mode degrade', 'Add a first-open, idempotent workspace prep flow that reconciles state (virgin / pql-user / partially-init / fully-init / previously-declined) rather than blindly running pql init. Two ordered gates: (1) non-git folder -> OFFER git init, default NO, with a guard that warns when a parent .git would create a nested repo; or pick another folder. (2) pql provisioning, now that pql ships bundled (T-355): config+index is the mandatory floor (the files/query/ignore engine); the planning layer (decisions/tickets + changelog hooks, D-67) is a CONTEXTUAL opt-in offered at first open of the Decisions/Tickets surface, with disclosure. The modal must disclose everything it writes (.gitignore entries, pql config, and — opt-in only — git hooks). Handle the friction.md gotcha: pql init writes to .git/hooks and ignores an existing core.hooksPath; do not clobber it. Writable repo w/o .pql = invalid-until-init. Unwritable repo (read-only mount / no perms) -> degrade to read mode: file tree + editor + D-79 grep stay live, pql surfaces dark behind a banner (depends on T-358 modes). Remember a decline in user scope keyed by repo path; provide an explicit ''initialize workspace'' command; no re-nagging.', 'backlog', 'medium', NULL, NULL, 'D-95', '2026-06-11 13:37:19', '2026-06-11 13:38:23', NULL, '7508bf6aa6dac26cfef8069f0b305c1b', 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 ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'task', '06FBDSPECQ0FPKB9SYTD7KZSBM', 'Declared extension modes (manifest) + classify builtins as read-mode-safe', 'Introduce a ''modes'' capability in the extension manifest (lib/extension/src/manifest.dart) as an open vocabulary: edit and read now, with remote/ssh/webui reserved (D-94). The extension host (lib/extension/src/host.dart) activates an extension only when the active workspace mode is in its declared set; an undeclared extension defaults to edit-only. Then classify the builtins: read-mode-safe = editor (view), files (tree + D-79 grep), git (status/log/diff viewing), terminal, claude; goes dark = pql (search/query/backlinks), decisions, tickets, graph; partial = problems (keep non-pql diagnostics, drop the pql.doctor row). This is the substrate read-mode degrade (T-357) gates on, and the seam the SSH-remote question (Q-23) is expected to resolve into.', 'backlog', 'medium', NULL, NULL, 'D-94', '2026-06-11 13:37:25', '2026-06-11 13:38:31', NULL, 'ba097febcafc3595656faa9a25b75a41', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-11 14:18:34', NULL, 'c987cb650ad845e72e459441e0d72aa9', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-11 14:59:53', NULL, '2b485906c225f811862fb5ec97ae37cf', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Live tail sub-card inside expanded Bash activity cards', 'Expanded Bash activity cards should be able to show a live, scrolling tail of a file-backed source the command is following, so the user can watch the same output Claude is tailing — connected lazily, only while the card is expanded.
**Motivation**
Long-running / tailing Bash steps render only a final result block once the command finishes (see screenshot in T-304 thread). The user can''t follow progress. We want an embedded scrolling terminal sub-card inside the expanded activity card for visibility.
**Hard constraint (feasibility, established by investigation)**
Claude Code CLI executes every Bash tool itself; clide only ingests stream-json events (stream_json_session.dart -> parseTranscriptChunk). A tool_result arrives as ONE complete block — clide never sees in-flight tool output, and the controller''s in-place upsert path is hardcoded for `partial-<msg_id>` prose deltas, not tool results (controller.dart). We cannot tap Claude''s subprocess fd. So a fully-general "mirror the running command''s stdout" is NOT possible.
**Viable scope: independent file-tail on expand**
- On card expand, parse the Bash command string (AssistantToolUse.input["command"], transcript_reader.dart) for a file-backed source: `tail -f X`, `tail -n N X`, `cat X`, `less X`, known log paths, or a background task writing to a file.
- If found, clide opens its OWN read-only follower — a NativePty running `tail -f X` (native_pty.dart already exposes Stream<Uint8List>) or a Dart file-watcher — and renders it in an embedded scrolling terminal sub-card, reusing TerminalView.
- Lazy lifecycle: connect on expand, disconnect/dispose on collapse. No connection until expanded.
- Read-only & safe: clide observes the same file; it never re-runs or intercepts Claude''s command.
- Graceful when there is no file-backed source (e.g. `git push ... | tail -25`, a pipe inside Claude''s process): show a muted "no independent source to follow" note rather than faking output.
**Where**
- Card body: conversation_card.dart / conversation_view.dart (_toolUseCollapser, the Bash tool card). Add an optional live-tail segment below the RESULT segment.
- Key the live connection on AssistantToolUse.toolUseId.
- Embedded terminal: reuse lib/src/terminal TerminalView + lib/src/pty NativePty.
**Open questions (settle in review / may need a Q-record)**
- Command-parsing surface: which commands/forms count as "tailable"? Keep it a small, explicit allowlist to avoid mis-following.
- Security: only follow paths inside the workspace? How to handle absolute paths outside the repo.
- Lifecycle when the underlying command has already finished (file static) vs still running.
**Acceptance**
- Expanding a Bash card whose command tails a file shows a live scrolling terminal sub-card that updates as the file grows; collapsing it tears the follower down.
- A Bash card with no file-backed source shows a clear "nothing to follow" affordance, never a broken/empty terminal.
- No follower process is spawned until the card is expanded.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 12:00:52', '2026-06-11 17:09:12', NULL, '050a31bfe1ef9cf220f306ab3922f84c', 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 ('06FBHBGHNEQTAEPGNJKN42C1E8', 'epic', NULL, 'Address 2026-06-11 Fable review (fable-ous.md)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:54:36', '2026-06-11 21:54:36', NULL, 'a4819e309facb7a990372e7e2eec2561', 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 ('06FBHBGHNEQTAEPGNJKN42C1E8', 'epic', NULL, 'Address 2026-06-11 Fable review (fable-ous.md)', 'Thirteen parallel subsystem reviewers + adversarial verification over ~58k LOC produced ~14 high-severity and ~35 medium findings plus systemic patterns and a feature backlog. Source: fable-ous.md (committed alongside this epic). Children are filed individually so they can land independently; feature proposals went to governance/questions as Q-records, not tickets.
Acceptance: all dragon (high-severity) findings resolved or formally rejected with a D-record; scorpions shipped or moved to follow-up work with rationale; rat-extermination and systemic-pattern batches closed; god-file split plans written into their tickets.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:54:36', '2026-06-11 21:54:44', NULL, '77ba1d6c54e7c950dd8eac675ce91d4f', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', NULL, 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 21:54:49', NULL, '925f4826638fbf190a370b08892d47cd', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 21:54:56', NULL, '0ce16b345f5e129abea63d6314b61bad', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', NULL, 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 21:55:01', NULL, '70beb5d53ac502257e86c82b520886f5', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 21:55:07', NULL, 'b9048d2475722e1d364044346f61cc2f', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', NULL, 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 21:55:13', NULL, 'f4f3b80272a12a8ec2aa561a353c0249', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'backlog', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 21:55:21', NULL, '50afa532cebd52e717eb790ad77f705e', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 21:55:26', NULL, 'dd2945aa9bd4bb2b4a908790243170c7', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 21:55:33', NULL, 'e6ce37ceaff954053227d353033886f1', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 21:55:38', NULL, '674a5d3b1a455b9f03a0a2383f7ca4e3', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 21:55:44', NULL, '749e1d21bafd775069eb747233632779', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 21:55:49', NULL, '468bd54e7a036cd26fa707df54c4ea97', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 21:55:56', NULL, '2fc8186724d131d03b43a68e46dd5f70', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 21:56:01', NULL, 'e0ff148be03a173e407a256ba72d19b6', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 21:56:09', NULL, '4f703d28761e63c84de96abdee86cd9e', 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 ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 21:56:13', NULL, '33ae9e7ac573f6d207297ca6f7758c98', 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 ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 21:56:19', NULL, 'dc274dbd0cd8573daae37acfcf54b6f9', 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 ('06FB0TNQM5W6VN98RQM6S22X28', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Claude bottom status bar often stays empty / doesn''t update', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
Likely suspects to investigate:
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.
Root cause found and verified by the 2026-06-11 Fable review (fable-ous.md, epic T-359): statusStream is a plain broadcast controller — the system/init event fires while spawn() is still awaiting a 256KB transcript-tail read, before the pane ever subscribes (lib/builtin/claude/src/claude_pane.dart:322, lib/builtin/claude/src/session_orchestrator.dart:222-225). Fix: seed from session.status on bind, or make the stream replay-latest (see the ValueStream systemic ticket under T-359).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 07:26:13', '2026-06-11 21:56:28', NULL, '1e65d7073c7407585c2e6a79c330881e', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 21:56:33', NULL, '4792af8e6089499f70a87245d351573d', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 21:56:40', NULL, '9efd0c7170841e18d9e21e29e3d75705', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 21:56:47', NULL, '9ab6c8b23458dd442a74791765541d0e', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 21:56:59', NULL, 'ee56f737a2da9d931af86807ea41db91', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 21:57:04', NULL, 'bbae48853b49cec865195440f76644d2', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 21:57:12', NULL, 'f9c5330a7954c66ad65efea30ac5539f', 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 ('06FBHC46SEHH8NQY481VMGK66R', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'A11y/i18n/theme gates hand-enumerate their subjects and have fallen behind lib/', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:17', '2026-06-11 21:57:17', NULL, 'bfb099275caa67eaae14c141f178dd3c', 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 ('06FBHC46SEHH8NQY481VMGK66R', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'A11y/i18n/theme gates hand-enumerate their subjects and have fallen behind lib/', 'The three a11y gate tests hand-enumerate their subjects and have measurably drifted from lib/: the contrast gate checks fewer themes than lib/main.dart:443-454 actually loads (catppuccin is silently unvalidated — the same theme list also drifted between main.dart and the testmode harness), and the i18n gate checks 4 of 8 namespaces. The gates stay green while covering less — worst kind of drift.
Fix (pattern: hand-enumerated lists drift; export the truth): make lib/ export one canonical const each for bundled themes, a11y gate subjects, and i18n namespaces; the gates and the testmode harness iterate those exports instead of their own lists. Add a meta-assertion where feasible (e.g. namespace list derived from the assets dir at test time) so a new theme/namespace cannot ship unvalidated.
Acceptance: gates fail if a newly added theme/namespace is not covered; catppuccin contrast-checked; all 8 i18n namespaces checked; testmode harness consumes the same exported theme list.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 21:57:17', '2026-06-11 21:57:26', NULL, '290bcc744fe4b90080d3617048018a81', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 21:57:31', NULL, '7a2cfcdde5be66cff55589267aa01d54', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 21:57:40', NULL, '4235607b75073718a6cba068cfde7c42', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 21:57:44', NULL, '454708a914320a3286f49db1d2ac7319', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 21:57:52', NULL, '2960aaecae3dfdedf5a660440e0f6d84', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 21:57:56', NULL, 'e2db785a676eadbf9dd3b78a655853f3', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 21:58:03', NULL, '79ae3a9297cb05e9afb8c22a01bf4ea4', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 21:58:08', NULL, '95294be3de8244f6177eb3bfc1f89abb', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 21:58:16', NULL, '7ee9fdb25f18eee232aa4c9a968ea22c', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 21:58:22', NULL, '21145621090d4acc5bbd8502de4e218e', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 21:58:33', NULL, 'c1f63e92d9c3708ee09c54a1b3ec9010', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 21:58:40', NULL, '3989745ea0ab1fbca279115f5e9a5088', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 21:58:49', NULL, '48334c3389949474ff1f341534126001', 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 ('06FBHCG84F4SPFW111CC4K26A8', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal conformance debt: HTS no-op, DECCKM unconsumed, mouse rows off-by-one, CPR 0-based, scrollback unreachable', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:55', '2026-06-11 21:58:55', NULL, 'a4f9ba0068e58b70a3567e1704dc1053', 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 ('06FBHCG84F4SPFW111CC4K26A8', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal conformance debt: HTS no-op, DECCKM unconsumed, mouse rows off-by-one, CPR 0-based, scrollback unreachable', 'The owned terminal fork fixes what it trips over but has no vttest-style conformance suite. Verified debt:
- HTS (set tab stop) is a no-op: calls isSetAt instead of setAt (lib/src/terminal/src/terminal.dart:423).
- DECCKM (cursor-keys application mode) is tracked but never consumed when encoding arrow-key input.
- Legacy X10/X11 mouse reporting rows are off-by-one AND the existing test enshrines the bug — fix both together.
- CPR (cursor position report) replies 0-based where every real terminal is 1-based.
- Scrollback is maintained but structurally unreachable: ViewportOffset.zero() is pinned on every build (lib/src/terminal/src/terminal_view.dart:224), so no scrolling UI path exists. (Blocks the semantic-terminal feature idea; fix is a prerequisite there.)
Approach: fix each item with a focused conformance test (vttest-style expectations); consider starting a small conformance_test.dart suite that future escape-sequence work extends. Coordinate with T-123 (parser split) and T-369 (SGR crash) which touch the same area.
Acceptance: each of the five items has a test that fails on the old behavior and passes after; mouse test corrected, not deleted.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:55', '2026-06-11 21:59:05', NULL, '0a7b313f101913232e0de3d528aa8be7', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 21:59:13', NULL, '73cdfc05da40b52f2d8712f589ba3734', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 21:59:20', NULL, 'eec49027f1c3e355070f5e36d0fcbc72', 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 ('06FBHCM1RBAF72SCZBKRTXJSYC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Streaming render path is O(n²): full re-parse + sync existsSync in build, O(n) re-derive per notify, full re-encode per delta', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:26', '2026-06-11 21:59:26', NULL, 'aa0a2a2ca4769d495fb48ae91d7340bd', 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 ('06FBHCM1RBAF72SCZBKRTXJSYC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Streaming render path is O(n²): full re-parse + sync existsSync in build, O(n) re-derive per notify, full re-encode per delta', 'Three compounding costs on every streamed token:
1. The whole markdown document re-parses inside build on each streaming delta, including sync existsSync() calls in the parse path (clide_markdown.dart) — sync I/O on the UI isolate per frame.
2. The conversation view re-derives its full item list O(n) on every controller notification.
3. Token streaming re-encodes the full reply text per delta (lib/builtin/claude/src/stream_json_session.dart:410-431) — O(n²) churn over a long reply.
Fix directions: cache parsed markdown per card keyed by content hash and only re-parse the tail/dirty card; move file-existence link checks off the build path (async + cache); make the session accumulate deltas in a StringBuffer instead of string concat re-encode; derive conversation items incrementally. Profile before/after with a long synthetic reply.
Acceptance: a 1000-delta synthetic stream does no existsSync in build (assert via injected fs seam or profiling harness), and per-delta work is bounded (no full-document re-parse); existing rendering tests green.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:26', '2026-06-11 21:59:37', NULL, 'ea41886fb8c4e6768980bacea594b182', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 21:59:42', NULL, 'fbee567d26a6c3bd5ee068ffbdd92a8d', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 21:59:50', NULL, 'cec13ab104ecf333fdf67ebbfc09a067', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 21:59:55', NULL, 'f25cab7fb88724093d582cc9d72e3976', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 22:00:05', NULL, '7fd6794ef2583587f29d082af5796bac', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 22:00:14', NULL, '8b87dd014b7e1925a59c3f9e0f9869ef', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 22:00:22', NULL, '8c66b985baf074abdc23d761ce10abae', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 22:00:29', NULL, '40c383af82388001daad2e99ee9d046e', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 22:00:37', NULL, 'c519d8d5e20db12785db272799738638', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-11 22:00:44', NULL, '940a9f2b3f061d4701f2eb86cb37f10e', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-11 22:01:02', NULL, '7eda667d2a552b742eb9a8d7d6069f0c', 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 ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 22:01:07', NULL, '0fc9a635cb4884d5974e8675baff028e', 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 ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 22:01:18', NULL, '3dd1b6a453892cb3c99e1fedb51cf371', 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 ('06FBHD2FWTDFYA00W4QXTE41M0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Ban catch (_) {} on I/O and lifecycle paths — sweep and log through kernel Logger', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:25', '2026-06-11 22:01:25', NULL, '7d028bfb1fd47d81c63b33e9ea931864', 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 ('06FBHD2FWTDFYA00W4QXTE41M0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Ban catch (_) {} on I/O and lifecycle paths — sweep and log through kernel Logger', 'The silent-swallow idiom turned an illegal-lookup-in-dispose into two resource leaks (T-366) and turned process-spawn failures into blank panes. Rule to apply: cleanup/teardown paths MAY swallow (with a comment saying why); spawn, read, and dispose-adjacent paths MUST log through the kernel Logger they already have access to.
Work: grep the tree for `catch (_)` and empty catch bodies; classify each site (cleanup vs load-bearing); add logging or rethrow on the load-bearing ones; leave a one-line justification comment on the legitimate swallows. Consider an analysis_options lint (avoid_catches_without_on_clauses or a custom assist) only if it can be enabled without suppressions — per repo policy no lint suppressions without approval.
Acceptance: zero unjustified empty catches on spawn/read/dispose paths; each remaining swallow carries a why-comment; any new logging visible in the kernel log during testmode.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:25', '2026-06-11 22:01:37', NULL, '0831ea558c5ebf30d44b8a5b3167a4ee', 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 ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Decide and apply the sync-I/O-in-async-handlers rule (new D-record; offload above a size threshold)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:43', '2026-06-11 22:01:43', NULL, 'bd614146db5d02385cf948333a489b99', 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 ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Decide and apply the sync-I/O-in-async-handlers rule (new D-record; offload above a size threshold)', 'The single-isolate app does sync I/O inside async IPC handlers: files.read does a sync 10MB read; the replace engine reads and rewrites workspace files synchronously — while grep right next to it fans out to isolates per D-79. There is no recorded rule, so each new handler guesses.
Work: claim a D-record (pql decisions claim D architecture "sync I/O policy in IPC handlers") deciding the rule — suggested: async File APIs by default in handlers; offload to an isolate above N KB (align N with the D-79 grep design); sync allowed only in pure-Dart test seams. Then apply it to files.read and replace_engine, citing the new D-NNN at each site.
Acceptance: D-record confirmed; files.read and search.replace no longer block the UI isolate on large files (test with a multi-MB fixture asserting the event loop stays responsive, e.g. a timer keeps firing).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:43', '2026-06-11 22:01:52', NULL, 'b0dfc4e32215d3500f671bce4342b965', 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 ('06FBHD6FQMXMQQQ877KMNCK6QC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Move path confinement to the dispatch layer, keyed off the co-registered schema', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:57', '2026-06-11 22:01:57', NULL, '369ef0dc4b4fa905a8858388bce405e3', 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 ('06FBHD6FQMXMQQQ877KMNCK6QC', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Move path confinement to the dispatch layer, keyed off the co-registered schema', 'Per-verb path confinement is a lottery: files.read remembered, search.replace half-remembered, editor.open/save forgot (T-363). The dispatcher registry (D-74) already co-registers a schema with every handler and the schema already knows which params are paths.
Fix: add a path-type marker to the schema layer (or derive from a naming convention recorded in the D-record amendment) and run path_safety confinement once in the dispatcher before the handler sees the request. Per-verb checks become defense-in-depth or get deleted.
Do after T-363 lands its point fix — this is the structural follow-up that prevents the next forgotten verb. Amend D-74 with the confinement rule.
Acceptance: a registered verb with a path param automatically rejects traversal/escape without any handler code; a regression test registers a synthetic verb and proves confinement applies; existing verbs unchanged behavior for in-workspace paths.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:01:57', '2026-06-11 22:02:08', NULL, '41cf2ffe1acca214218b6804b73ae191', 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 ('06FBHD8F2NBEFZNPKSJ9W253J0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Copy-paste dedup sweep: welcome/FileActions, palette/quick-open twins, three tail-followers, five _userErr, five git sandboxes, two ANSI flag enums', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:14', '2026-06-11 22:02:14', NULL, '445a9ac5a5b22f8d2efce7116a08a8ae', 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 ('06FBHD8F2NBEFZNPKSJ9W253J0', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Copy-paste dedup sweep: welcome/FileActions, palette/quick-open twins, three tail-followers, five _userErr, five git sandboxes, two ANSI flag enums', 'Verified duplication clusters, each small, together the repo''s main rot vector:
- Welcome screen clones FileActions'' entire open-folder flow verbatim — call FileActions instead (coordinate with T-383).
- Command palette and quick-open are ~230-line near-twins — extract the shared list-overlay+filter core.
- Three private tail-a-growing-file implementations in the claude builtin alone — one shared follower (coordinate with T-373, which gives it the chunked decoder).
- Five hand-rolled _userErr helpers across handlers — one shared error-shaping helper.
- Five copy-pasted git test sandboxes, none isolating host git config (set GIT_CONFIG_GLOBAL/HOME in the shared fixture — flaky-test risk today).
- Two parallel ANSI flag enums that already drifted: strikethrough is stored but never painted — unify, then paint strikethrough.
One commit per cluster. Acceptance: each cluster has a single implementation with the call sites migrated and a test guarding the shared piece; git sandboxes isolated from host config.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:14', '2026-06-11 22:02:25', NULL, '5f5034f42fcf5741805d8d4ef81e6376', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 22:02:31', NULL, '6aaf2b8af3b6596bcf35b13bcdc0a2b5', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 22:02:40', NULL, '55e8cdd77883a14348bbb7e6f288529c', 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 ('06FBHDC7B2MFQZVR1K9E30FXDR', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Docs drift at the front door: CLAUDE.md/README tmux claim superseded by D-75/D-77; README version + stub features overstated', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:44', '2026-06-11 22:02:44', NULL, '731e453c28c5fe59ebefdd9719e69d76', 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 ('06FBHDC7B2MFQZVR1K9E30FXDR', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Docs drift at the front door: CLAUDE.md/README tmux claim superseded by D-75/D-77; README version + stub features overstated', 'CLAUDE.md and README still say "tmux owns Claude session persistence (D-41)" — superseded by D-75/D-77 per docs/architecture.md. README says "Pre-v2.0 (2.0.0-dev)" while pubspec is at v2.3.3, and headlines "canvas and graph surfaces" that are a 17-line stub and a flat ListView respectively (T-7 epic was cancelled).
Fix: rewrite the stale paragraphs in both files to match current architecture (clide-managed stream-json sessions); fix the README version line (or derive it); demote canvas/graph to roadmap wording or drop them. The repo''s honesty is its brand; the README is the one off-brand surface.
Acceptance: no tmux-persistence claim outside historical D-records; README version matches pubspec; every README feature claim maps to shipped behavior.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:44', '2026-06-11 22:02:54', NULL, 'b4ab7763b2e791c559777dfa83c78fa3', 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 ('06FBHDE5A3965GNRFS5WPZW878', 'story', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Close the release loop: back-tag 2.2.02.3.3, fix ci/release.sh, add tagging to the release ritual, widen the fast-path regex', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:03:00', '2026-06-11 22:03:00', NULL, '4efa9cc5cf36800ac88e9da767a20654', 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 ('06FBHDE5A3965GNRFS5WPZW878', 'story', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Close the release loop: back-tag 2.2.02.3.3, fix ci/release.sh, add tagging to the release ritual, widen the fast-path regex', 'No git tag since v2.1.0 despite five CHANGELOG releases; ci/release.sh exits 64 and still references the dissolved sidecar; the pre-push fast path''s safety argument cites "release CI on tagged versions" that does not exist; and the fast-path regex skips ALL tests for pushes touching test/, ci/, or the hook itself.
Work items (separable commits):
1. Back-tag v2.2.0 through v2.3.3 at the release-cut commits (find them via the CHANGELOG version-bump commits).
2. Rewrite ci/release.sh for the single-process architecture or delete it and fold release steps into the Makefile — either way, no stub that exits 64.
3. Add tagging to the release ritual in .claude/skills/git-commit/SKILL.md (version bump + changelog move + tag in one documented step).
4. Widen the pre-push fast-path regex so changes under test/, ci/, and the hook itself run the full gate.
This is also the blocking prerequisite the T-47 (self-update) refinement identified. Acceptance: git tag lists every released version; release.sh (or its replacement) runs end-to-end; fast-path regex covered by a hook test if feasible.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-11 22:03:00', '2026-06-11 22:03:10', NULL, '223b9403ea9923579214e54b184969eb', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-11 22:03:21', NULL, 'adf4ca4c52a805c0c686a18b317e5e08', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-11 22:03:26', NULL, 'a0a11f91eda4b9041a5b059416cdb719', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
Target layout:
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-11 22:09:19', NULL, 'ea4c03fb65ff1d332c15ed7df7a865e3', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-11 22:09:30', NULL, 'dd3a3f6ec9a78df6044d44713218e989', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', 'lib/builtin/claude/src/claude_meta_sidebar.dart is 1192 LOC. Split plan (verified against the file 2026-06-12). Public API (ClaudeMetaSidebar widget + SidebarTab enum) stays in the root file — tests and extension.dart need zero import changes.
Inventory: consts _labelColumnWidth/_rowPitch (43-44), SidebarTab enum (46), ClaudeMetaSidebar (48-79), _ClaudeMetaSidebarState monolith (81-638: lifecycle, stats polling, team membership streams, primary-session binding, broker subscription, config listener, inject state, accordion state, plus the three tab bodies), _ConfigSection/_ConfigPermKind enums (641/644), _MetaSection/_MetaRow models (646-657), _AgentRosterRow(+State) (680-928), _permissionModeBadge + _PermissionModeBadge T-181 (935-1009), _IconButton (1012-1039), _InjectTextField (1043-1070), _TaskRow T-171 (1077-1141), _TabStrip (1145-1192).
Target layout under lib/builtin/claude/src/meta_sidebar/: models.dart (enums + _MetaSection/_MetaRow + layout consts), activity_tab.dart (ActivityTabView ~70 LOC, from _activityBody/_runtimeSection 272-302), team_tab.dart (TeamTabView ~120 LOC, from _teamBody/_taskSection 304-380, props-driven with callbacks), config_tab.dart (ConfigTabView ~200 LOC, from _configBody family 382-597; accordion _expanded state stays in parent, passed as prop+callback), roster_row.dart (_AgentRosterRow ~250 LOC incl. bypass-confirm state), permission_badge.dart (~80), task_row.dart (~70), tab_strip.dart (~55), icon_button.dart + inject_field.dart (~30 each — keep here initially; promote to lib/widgets/ only when a second consumer appears). Root file shrinks to ~150 LOC of lifecycle + event bindings + tab switch.
Execution order (each phase independently green): 1) primitives (icon button, inject field, permission badge, models); 2) stateless tab views (activity, config, tab strip) with prop threading; 3) team tab + roster row (most orchestrator coupling: verify show/hide, mute, inject submit-and-clear, shift-click bypass confirm, fork, close, task reassign cycle, auto-front on TeamMemberJoined at line 158); 4) cleanup of moved methods from the root state.
Tests: test/builtin/claude/claude_meta_sidebar_test.dart imports only the root file and public symbols — no changes needed; no goldens reference the sidebar. Caveat from the rat sweep (T-385): TeamMemberJoined currently has no emitter — coordinate before investing in the team tab plumbing.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-11 22:09:55', NULL, '555ee8c21aa930e2310712747c9b2d97', 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 ('06FB0TNQM4RJ9K19P5AX14RHRR', 'task', '06FB0TNQM4HGK6KR0314P2M32M', 'split lib/src/terminal/.../escape/parser.dart (1139 LOC)', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
- parser.dart — entry point + state machine driver
- esc_handlers.dart — single-char ESC dispatch table + handlers
- csi_handlers.dart — CSI parameter parsing + handlers
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
Each handler module exports a registrar that the driver wires at construction.
Done when:
- parser.dart < 400 LOC
- All existing parser tests pass without changes
- No new public surface; everything stays library-private
Source: T-107 / consultants.md "Code quality — Findings — [Major]".
Split breakdown from the 2026-06-11 Fable review (epic T-359), verified against the file 2026-06-12:
Structure today: main parser + routing (11-116), CSI core _escHandleCSI (196-209) + _consumeCsi (217-272), _csiHandlers table of 27 final bytes (274-304), cursor movement handlers (306-807), erase/scroll/line/char ops (809-945), SGR monolith (411-622, 212 LOC), mode set/reset (395-409, 946-1030), DA/DSR (334-343, 624-635), window manipulation (658-712), OSC (1034-1110), _Csi state object (1113-1131 — note the commented-out `intermediates` field at 1117/1125).
Target layout under escape/: parser.dart keeps EscapeParser (queue, tokenization, top-level dispatch, ~400 LOC); csi_parser.dart (CsiSequence value object — prefix, params, RESTORED intermediates, finalByte — plus the consume logic from _consumeCsi); csi_handlers.dart (dispatch table, now keyed on final byte + intermediates); cursor_handlers.dart; sgr_handler.dart (the 411-622 monolith); mode_handler.dart; osc_parser.dart + osc_handlers.dart. EscapeHandler interface unchanged. Preserve the zero-allocation/reset-able design goal noted at lines 14-16.
Bug this split must fix (verified): _consumeCsi DISCARDS intermediate bytes — lines 258-261 have `// intermediates.add(char);` commented out and `continue`, so CSI Ps SP q (DECSCUSR, cursor style) and CSI ! p / SP-intermediate forms dispatch on the bare final byte and fall to unknownCSI. Fix: restore the intermediates field on CsiSequence, capture them during consume, dispatch on (intermediates, finalByte), and add EscapeHandler.setCursorStyle for DECSCUSR. (DECSTR is CSI ! p — soft terminal reset — same intermediate mechanism.)
Related bug with its own ticket (T-369): unguarded params[i+1] lookahead in SGR 38/48 at lines ~502/512/547/557 (RangeError on truncated sequences) + colon-form sub-parameters unhandled. The split makes the fix natural: sgr_handler.dart owns guarded lookahead helpers; if T-369 lands first, carry its tests over; if this lands first, fix it inside sgr_handler.dart and close T-369 with it.
Tests: test/terminal/escape/parser_test.dart (786 LOC) splits along the same seams — keep parser_test.dart for top-level dispatch/SBC/rollback, add csi_parser_test.dart (intermediates capture, DECSCUSR), sgr_handler_test.dart (bounds + colon form + 256/RGB), mode_handler_test.dart, osc_parser_test.dart, window/DA splits as convenient. The _RecordingHandler fixture is reusable across all of them.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 10:29:02', '2026-06-11 22:10:19', NULL, '90450ed5bd162fe65bf0426662d65ee4', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 22:14:48', NULL, '3356972c53b2ba4079a3c86e7d836c09', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'PTY master fd leaks on every natural child exit', 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:54:49', '2026-06-11 22:18:25', NULL, '62168ec7a6bcdd154adc173c9cf9275f', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 22:18:56', NULL, 'a6c200ce295ef6e192688761f664ac9d', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Kernel lookup in dispose() leaks shell PTYs and settings listeners', 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:01', '2026-06-11 22:24:07', NULL, '167f8b01f1fcf2ef0bcc845585ca17aa', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:24:34', NULL, '2cf4e96fedd8de400239157f53ac0b94', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:26:15', NULL, '1340fb7aac3cdf06476ce74248fda6c0', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:26:38', NULL, '8cc428e286b918b46744a649ab1979f0', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:30:24', NULL, 'b64b55f7d40149848fc687efe1cf8f32', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 22:30:48', NULL, 'aa0f09e575dd2d3232f34485982c1ec5', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 22:32:08', NULL, 'f83e31fd330c98a311a3da10cba1c655', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 22:32:29', NULL, '0d067ce0eded0fcc158d09ea793bb223', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'search.replace silently ignores its include/exclude globs', 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:38', '2026-06-11 22:34:31', NULL, 'e86c024d6f8a6d9cd9b7273522b54511', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 22:34:51', NULL, 'ff2c4e85e11c02c88eff6f8e87348d34', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 22:39:04', NULL, '50c42cb7af7715e9ea6417212be0ebdc', 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 ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:39:32', NULL, 'c0fdac5acbf4332f8d2a632edbc94085', 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 ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:44:05', NULL, 'ab0fad47ab342a3578b9fec64dd886e7', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:44:30', NULL, 'fa9b28e5617b452b34d21b58d67ed3ca', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:46:46', NULL, '1a642a72ded99e1710d9cd5e4b73a591', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 22:47:12', NULL, '050a42aae5477de3d6013adfc6231814', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 22:53:45', NULL, '225dafdd282850fa3fc420acb0084355', 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 ('06FB0TNQM6K1QRC911JMQ9C960', 'bug', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Resume hang has no timeout/fallback; resume decided by file-exists not content', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause.
(a) No timeout / no fallback on the init-event wait.
On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume <id>'' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · <id>'' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide.
(b) Resume is decided by file existence, not resumable content.
claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a).
Acceptance:
1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery.
2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript.
3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count.
4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume.
Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257.
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.
T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-11 22:53:52', NULL, '06cc0a2e629ee7a192d24286f58af27f', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:54:18', NULL, '320e3e3eeb46d8a70e5ecfd5c903563d', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:57:42', NULL, '14de71ed19e921a370001097887a7e6f', 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 ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 23:01:24', NULL, '490fa0773537afc3ec2be0756960dc05', 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 ('06FB0TNQM5W6VN98RQM6S22X28', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Claude bottom status bar often stays empty / doesn''t update', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
Likely suspects to investigate:
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.
Root cause found and verified by the 2026-06-11 Fable review (fable-ous.md, epic T-359): statusStream is a plain broadcast controller — the system/init event fires while spawn() is still awaiting a 256KB transcript-tail read, before the pane ever subscribes (lib/builtin/claude/src/claude_pane.dart:322, lib/builtin/claude/src/session_orchestrator.dart:222-225). Fix: seed from session.status on bind, or make the stream replay-latest (see the ValueStream systemic ticket under T-359).', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 07:26:13', '2026-06-11 23:04:46', NULL, '97024e51051e9ac66e65376e953a5faf', 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 ('06FBHD098CV2N73823KX4Z99P4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ValueStream (replay-latest) wrapper; retrofit statusStream, busyStream, pendingPromptStream', 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 22:01:07', '2026-06-11 23:04:46', NULL, '9cd3d1777cfbe56c117a91aa3f303855', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 23:05:16', NULL, '09bb1689e960768202cfe42d30caf567', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal panes spawn in Directory.current, not the open project root', 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:42', '2026-06-11 23:06:26', NULL, '8cad068c28a7a0cb6772a5fbdcbc34c3', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:07:01', NULL, 'f2df2148315332d48e2bf66cc2942bd3', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Orchestrator.spawn() races itself — concurrent spawns for one id leak a live claude process', 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:08:13', NULL, 'fde3f9e912ea1d25256003ef56dcf491', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:08:40', NULL, '8e8a63e0617c3707db1cf5621f181b63', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:10:56', NULL, '1cd70120bb752815eeaebb58e57b1a1c', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 23:11:24', NULL, '03c848a027a4ae292e854398358eb150', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'IPC server dispatch is not serial; shared StringBuffer framing can drop/double lines', 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 23:13:18', NULL, 'bbc0aa50303eac50666cbcfa140065a1', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:13:49', NULL, '71635348a33e10c188ea10581c39ca57', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:16:28', NULL, 'e23aad60b9f75e50cb305e727254e094', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 23:16:53', NULL, 'e3bf2108898cf82d75be7a0da17443ca', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Markdown renderer: hard breaks and images render as empty text', 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:13', '2026-06-11 23:18:22', NULL, '5ec0ffc2676c2f31476249301ae2869b', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 23:18:47', NULL, '20850287af7c135adc76d53f200f5209', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 23:20:52', NULL, 'fb8aa4e3ae8a67be11cb051975e3804c', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Notifications service renders nowhere — warnings vanish into an unrendered list', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.
Resolved with option (a): Notifications now takes the kernel MessageBus and publishes every notification to the toast channel (severity-mapped, ''title — message''); the in-memory active list stays for API compatibility. No tray built.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:59:55', '2026-06-11 23:20:59', NULL, 'e98115731bb2c593bd17ae945a79d260', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 23:21:26', NULL, '7c76dbeb0ea389b836633f9ca5df2e68', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 23:24:31', NULL, '259cc1f4603bb7a7b4101d963ea1a9ab', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Welcome screen no-ops: Clone-from-git and Start-a-Claude-session tiles do nothing; advertised shortcuts don''t exist', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.
Resolved by removal, not stub flows: the two inert tiles are gone (each returns with its real flow — clone is honorable-mention territory in Q-49), the Open-folder shortcut glyph now matches the actual ctrl+o binding, and the tips card was corrected to six bindings that actually exist (quick open, palette, sidebar/context collapse, find-in-files, focus mode — the old card advertised four bindings that were never registered).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:14', '2026-06-11 23:24:39', NULL, '49859fb995fa3e1dbd916a153c651c40', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 23:25:05', NULL, 'd51d7435910cc30ef2b8893ae05ba6a1', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'claude builtin returns ok with an error payload in 16 handlers — honor the D-6 exit-code contract', '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 23:28:30', NULL, 'd3e727b4dd8848404845c9cf5af32891', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 23:28:58', NULL, 'aa06de288d298b3e4bf6add9e128e0ff', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 23:31:55', NULL, 'fdf8ae8544253d4ae0b79ab17e0d9003', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 23:32:21', NULL, '5f245fb27177b40eb5b45c90ff7744c3', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Extension activation is not transactional; disable ignores dependents; registries clobber on id collision', 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:40', '2026-06-11 23:35:05', NULL, 'abc89daa9f0b06a8c29747fc30333b65', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:35:29', NULL, 'c1460818fcd188185216d5ededdb4c0f', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:38:58', NULL, '5d99bf87cf43f2a86380a81104f3e70e', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'review', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:39:03', NULL, '15d8c06d3292a0f55f8e44ca369b6699', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:01:13', NULL, '5232eeb9532e61a3d04e8908d0586a49', 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 ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rewire team roster UI to TeamBroker membership; delete ghost TeamMemberJoined/Left events', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '68babe90a7ccbbc7b554e05243adb9ce', 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 ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rewire team roster UI to TeamBroker membership; delete ghost TeamMemberJoined/Left events', 'Split out of the T-385 rat sweep: nothing in production constructs TeamMemberJoined/TeamMemberLeft (verified — only the type definition and tests), yet the team roster surfaces (lib/builtin/claude/src/team_panel_host.dart and the meta sidebar Team tab) populate their member lists EXCLUSIVELY from kernel.events.on<TeamMemberJoined>() — ghost-fed UI. The real membership source exists: TeamBroker (orchestrator.broker, T-170/T-171) tracks members via addMember/removeMember on team spawn/close.
Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts.
Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:12:04', NULL, '57f7e568fe7c411fc4b45c6b140d8167', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'done', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:23:13', NULL, '9563a30ce5944315595343d612572909', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too):
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
- ToolCheck — zero callers.
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.
Done 2026-06-12 across six commits. Notes: ColumnHat''s file carried the LIVE hatHeight constant (app hat bar + menu bar) — moved to widgets/src/chrome_metrics.dart before deleting the dead widget. TranscriptPublisher class removed; the ClaudeConversation addressing constants stay (still consumed). The TeamMemberJoined ghost-event rewiring is real work, split out as T-396. mocktail dropped with D-25 amended (hand-rolled fakes throughout). ptyc binary untracked+deleted (no licenses.yaml entry existed). Coverage rose 95.03% → 95.13% with the dead denominator gone; full push-check green.', 'done', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:23:24', NULL, '0fedb8cda245d2ac1ece80f565e53049', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
Target layout:
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:23:56', NULL, '06f8ead95c7934a07b4b74813f74f001', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
Target layout:
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:29:57', NULL, '472bd43765ad37a110c7760a5da6f814', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
Target layout:
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.
Done 2026-06-12. One deviation from the plan: the shell pieces went to lib/src/shell/ rather than lib/widgets/ + lib/builtin/hat/ — SlotHost/RootLayout know kernel + contribution types (not widget primitives), and the hat bar isn''t extension-shaped (no contributions), so neither home fit. app.dart kept the planned re-exports; zero test edits needed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:30:11', NULL, '72f1fd3cab79bd6fcd777fa857b0f245', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', 'lib/builtin/claude/src/claude_meta_sidebar.dart is 1192 LOC. Split plan (verified against the file 2026-06-12). Public API (ClaudeMetaSidebar widget + SidebarTab enum) stays in the root file — tests and extension.dart need zero import changes.
Inventory: consts _labelColumnWidth/_rowPitch (43-44), SidebarTab enum (46), ClaudeMetaSidebar (48-79), _ClaudeMetaSidebarState monolith (81-638: lifecycle, stats polling, team membership streams, primary-session binding, broker subscription, config listener, inject state, accordion state, plus the three tab bodies), _ConfigSection/_ConfigPermKind enums (641/644), _MetaSection/_MetaRow models (646-657), _AgentRosterRow(+State) (680-928), _permissionModeBadge + _PermissionModeBadge T-181 (935-1009), _IconButton (1012-1039), _InjectTextField (1043-1070), _TaskRow T-171 (1077-1141), _TabStrip (1145-1192).
Target layout under lib/builtin/claude/src/meta_sidebar/: models.dart (enums + _MetaSection/_MetaRow + layout consts), activity_tab.dart (ActivityTabView ~70 LOC, from _activityBody/_runtimeSection 272-302), team_tab.dart (TeamTabView ~120 LOC, from _teamBody/_taskSection 304-380, props-driven with callbacks), config_tab.dart (ConfigTabView ~200 LOC, from _configBody family 382-597; accordion _expanded state stays in parent, passed as prop+callback), roster_row.dart (_AgentRosterRow ~250 LOC incl. bypass-confirm state), permission_badge.dart (~80), task_row.dart (~70), tab_strip.dart (~55), icon_button.dart + inject_field.dart (~30 each — keep here initially; promote to lib/widgets/ only when a second consumer appears). Root file shrinks to ~150 LOC of lifecycle + event bindings + tab switch.
Execution order (each phase independently green): 1) primitives (icon button, inject field, permission badge, models); 2) stateless tab views (activity, config, tab strip) with prop threading; 3) team tab + roster row (most orchestrator coupling: verify show/hide, mute, inject submit-and-clear, shift-click bypass confirm, fork, close, task reassign cycle, auto-front on TeamMemberJoined at line 158); 4) cleanup of moved methods from the root state.
Tests: test/builtin/claude/claude_meta_sidebar_test.dart imports only the root file and public symbols — no changes needed; no goldens reference the sidebar. Caveat from the rat sweep (T-385): TeamMemberJoined currently has no emitter — coordinate before investing in the team tab plumbing.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-12 00:30:17', NULL, '9c58fdb1224831d870110543a53389a6', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', 'lib/builtin/claude/src/claude_meta_sidebar.dart is 1192 LOC. Split plan (verified against the file 2026-06-12). Public API (ClaudeMetaSidebar widget + SidebarTab enum) stays in the root file — tests and extension.dart need zero import changes.
Inventory: consts _labelColumnWidth/_rowPitch (43-44), SidebarTab enum (46), ClaudeMetaSidebar (48-79), _ClaudeMetaSidebarState monolith (81-638: lifecycle, stats polling, team membership streams, primary-session binding, broker subscription, config listener, inject state, accordion state, plus the three tab bodies), _ConfigSection/_ConfigPermKind enums (641/644), _MetaSection/_MetaRow models (646-657), _AgentRosterRow(+State) (680-928), _permissionModeBadge + _PermissionModeBadge T-181 (935-1009), _IconButton (1012-1039), _InjectTextField (1043-1070), _TaskRow T-171 (1077-1141), _TabStrip (1145-1192).
Target layout under lib/builtin/claude/src/meta_sidebar/: models.dart (enums + _MetaSection/_MetaRow + layout consts), activity_tab.dart (ActivityTabView ~70 LOC, from _activityBody/_runtimeSection 272-302), team_tab.dart (TeamTabView ~120 LOC, from _teamBody/_taskSection 304-380, props-driven with callbacks), config_tab.dart (ConfigTabView ~200 LOC, from _configBody family 382-597; accordion _expanded state stays in parent, passed as prop+callback), roster_row.dart (_AgentRosterRow ~250 LOC incl. bypass-confirm state), permission_badge.dart (~80), task_row.dart (~70), tab_strip.dart (~55), icon_button.dart + inject_field.dart (~30 each — keep here initially; promote to lib/widgets/ only when a second consumer appears). Root file shrinks to ~150 LOC of lifecycle + event bindings + tab switch.
Execution order (each phase independently green): 1) primitives (icon button, inject field, permission badge, models); 2) stateless tab views (activity, config, tab strip) with prop threading; 3) team tab + roster row (most orchestrator coupling: verify show/hide, mute, inject submit-and-clear, shift-click bypass confirm, fork, close, task reassign cycle, auto-front on TeamMemberJoined at line 158); 4) cleanup of moved methods from the root state.
Tests: test/builtin/claude/claude_meta_sidebar_test.dart imports only the root file and public symbols — no changes needed; no goldens reference the sidebar. Caveat from the rat sweep (T-385): TeamMemberJoined currently has no emitter — coordinate before investing in the team tab plumbing.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-12 00:39:04', NULL, '130bc44e5a82ac00eb280f13cc8c19c3', 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 ('06FB0TNQM4RJ9K19P5AX14RHRR', 'task', '06FB0TNQM4HGK6KR0314P2M32M', 'split lib/src/terminal/.../escape/parser.dart (1139 LOC)', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
- parser.dart — entry point + state machine driver
- esc_handlers.dart — single-char ESC dispatch table + handlers
- csi_handlers.dart — CSI parameter parsing + handlers
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
Each handler module exports a registrar that the driver wires at construction.
Done when:
- parser.dart < 400 LOC
- All existing parser tests pass without changes
- No new public surface; everything stays library-private
Source: T-107 / consultants.md "Code quality — Findings — [Major]".
Split breakdown from the 2026-06-11 Fable review (epic T-359), verified against the file 2026-06-12:
Structure today: main parser + routing (11-116), CSI core _escHandleCSI (196-209) + _consumeCsi (217-272), _csiHandlers table of 27 final bytes (274-304), cursor movement handlers (306-807), erase/scroll/line/char ops (809-945), SGR monolith (411-622, 212 LOC), mode set/reset (395-409, 946-1030), DA/DSR (334-343, 624-635), window manipulation (658-712), OSC (1034-1110), _Csi state object (1113-1131 — note the commented-out `intermediates` field at 1117/1125).
Target layout under escape/: parser.dart keeps EscapeParser (queue, tokenization, top-level dispatch, ~400 LOC); csi_parser.dart (CsiSequence value object — prefix, params, RESTORED intermediates, finalByte — plus the consume logic from _consumeCsi); csi_handlers.dart (dispatch table, now keyed on final byte + intermediates); cursor_handlers.dart; sgr_handler.dart (the 411-622 monolith); mode_handler.dart; osc_parser.dart + osc_handlers.dart. EscapeHandler interface unchanged. Preserve the zero-allocation/reset-able design goal noted at lines 14-16.
Bug this split must fix (verified): _consumeCsi DISCARDS intermediate bytes — lines 258-261 have `// intermediates.add(char);` commented out and `continue`, so CSI Ps SP q (DECSCUSR, cursor style) and CSI ! p / SP-intermediate forms dispatch on the bare final byte and fall to unknownCSI. Fix: restore the intermediates field on CsiSequence, capture them during consume, dispatch on (intermediates, finalByte), and add EscapeHandler.setCursorStyle for DECSCUSR. (DECSTR is CSI ! p — soft terminal reset — same intermediate mechanism.)
Related bug with its own ticket (T-369): unguarded params[i+1] lookahead in SGR 38/48 at lines ~502/512/547/557 (RangeError on truncated sequences) + colon-form sub-parameters unhandled. The split makes the fix natural: sgr_handler.dart owns guarded lookahead helpers; if T-369 lands first, carry its tests over; if this lands first, fix it inside sgr_handler.dart and close T-369 with it.
Tests: test/terminal/escape/parser_test.dart (786 LOC) splits along the same seams — keep parser_test.dart for top-level dispatch/SBC/rollback, add csi_parser_test.dart (intermediates capture, DECSCUSR), sgr_handler_test.dart (bounds + colon form + 256/RGB), mode_handler_test.dart, osc_parser_test.dart, window/DA splits as convenient. The _RecordingHandler fixture is reusable across all of them.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-18 10:29:02', '2026-06-12 00:39:41', NULL, 'e8810fd23adc05225565eaa3af23eb53', 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 ('06FB0TNQM4RJ9K19P5AX14RHRR', 'task', '06FB0TNQM4HGK6KR0314P2M32M', 'split lib/src/terminal/.../escape/parser.dart (1139 LOC)', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
- parser.dart — entry point + state machine driver
- esc_handlers.dart — single-char ESC dispatch table + handlers
- csi_handlers.dart — CSI parameter parsing + handlers
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
Each handler module exports a registrar that the driver wires at construction.
Done when:
- parser.dart < 400 LOC
- All existing parser tests pass without changes
- No new public surface; everything stays library-private
Source: T-107 / consultants.md "Code quality — Findings — [Major]".
Split breakdown from the 2026-06-11 Fable review (epic T-359), verified against the file 2026-06-12:
Structure today: main parser + routing (11-116), CSI core _escHandleCSI (196-209) + _consumeCsi (217-272), _csiHandlers table of 27 final bytes (274-304), cursor movement handlers (306-807), erase/scroll/line/char ops (809-945), SGR monolith (411-622, 212 LOC), mode set/reset (395-409, 946-1030), DA/DSR (334-343, 624-635), window manipulation (658-712), OSC (1034-1110), _Csi state object (1113-1131 — note the commented-out `intermediates` field at 1117/1125).
Target layout under escape/: parser.dart keeps EscapeParser (queue, tokenization, top-level dispatch, ~400 LOC); csi_parser.dart (CsiSequence value object — prefix, params, RESTORED intermediates, finalByte — plus the consume logic from _consumeCsi); csi_handlers.dart (dispatch table, now keyed on final byte + intermediates); cursor_handlers.dart; sgr_handler.dart (the 411-622 monolith); mode_handler.dart; osc_parser.dart + osc_handlers.dart. EscapeHandler interface unchanged. Preserve the zero-allocation/reset-able design goal noted at lines 14-16.
Bug this split must fix (verified): _consumeCsi DISCARDS intermediate bytes — lines 258-261 have `// intermediates.add(char);` commented out and `continue`, so CSI Ps SP q (DECSCUSR, cursor style) and CSI ! p / SP-intermediate forms dispatch on the bare final byte and fall to unknownCSI. Fix: restore the intermediates field on CsiSequence, capture them during consume, dispatch on (intermediates, finalByte), and add EscapeHandler.setCursorStyle for DECSCUSR. (DECSTR is CSI ! p — soft terminal reset — same intermediate mechanism.)
Related bug with its own ticket (T-369): unguarded params[i+1] lookahead in SGR 38/48 at lines ~502/512/547/557 (RangeError on truncated sequences) + colon-form sub-parameters unhandled. The split makes the fix natural: sgr_handler.dart owns guarded lookahead helpers; if T-369 lands first, carry its tests over; if this lands first, fix it inside sgr_handler.dart and close T-369 with it.
Tests: test/terminal/escape/parser_test.dart (786 LOC) splits along the same seams — keep parser_test.dart for top-level dispatch/SBC/rollback, add csi_parser_test.dart (intermediates capture, DECSCUSR), sgr_handler_test.dart (bounds + colon form + 256/RGB), mode_handler_test.dart, osc_parser_test.dart, window/DA splits as convenient. The _RecordingHandler fixture is reusable across all of them.', 'done', 'low', NULL, NULL, NULL, '2026-05-18 10:29:02', '2026-06-12 00:50:21', NULL, '9b4f05fb5786f50b1908b85cc99cbb6f', 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 ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'task', '06FB0TNQM4HGK6KR0314P2M32M', 'support DECSCUSR cursor-shape sequences (CSI Ps SP q)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 00:52:25', '2026-06-12 00:52:25', NULL, '7b313d1954ab7f7346365ea058dd1787', 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 ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'task', '06FB0TNQM4HGK6KR0314P2M32M', 'support DECSCUSR cursor-shape sequences (CSI Ps SP q)', 'Follow-up to T-123. The escape parser now captures CSI intermediate bytes (0x20-0x2f) on _Csi.intermediates and routes any intermediate-bearing sequence to unknownCSI instead of mis-dispatching on the bare final byte. Nothing implements those forms yet.
Scope: implement DECSCUSR — `CSI Ps SP q` — cursor shape + blink:
- 0/1 blinking block (default), 2 steady block, 3 blinking underline, 4 steady underline, 5 blinking bar, 6 steady bar.
Plan:
1. lib/src/terminal/src/core/escape/handler.dart — add `void setCursorShape(<enum> shape, {required bool blink})` (or an int-style variant matching the existing surface; note resetCursorStyle() there is SGR pen state, NOT cursor shape — pick a name that cannot be confused with it).
2. lib/src/terminal/src/core/escape/csi_handlers.dart — dispatch: in parser.dart _escHandleCSI, intermediate-bearing sequences currently all fall to unknownCSI; add a lookup keyed on (intermediates, finalByte) — a simple `if (_csi.intermediates is [0x20] && finalByte == ''q'')` check is fine until a second form exists.
3. lib/src/terminal/src/core/terminal.dart — implement the handler method: store a cursorStyle field, notify observers.
4. Renderer (lib/src/terminal/src/ui/) — draw underline/bar cursors; today only block is painted. This is the bulk of the work; check TerminalPainter for the cursor paint path.
5. Tests: parser dispatch (test/terminal/escape/parser_test.dart has a _RecordingHandler fixture + an existing ''CSI intermediate bytes (T-123)'' group with a DECSCUSR placeholder test that expects unknownCSI — update it), terminal state, painter golden if shape rendering lands.
DECSTR (`CSI ! p`, soft reset) is a separate, smaller follow-up — same intermediates mechanism, maps to a subset of the existing reset paths; file separately if wanted.
Done when: claude/vim cursor-shape changes (insert vs normal mode) render as bar vs block in the terminal pane.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 00:52:25', '2026-06-12 00:52:40', NULL, '5c07c49f5ad28c005a77ba26434e66d7', 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 ('06FB3DKQQJ583944DG8561VQ3G', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 1 — backend transport seam (DaemonTransport, prove locally)', 'Model-independent backbone. Make the local app talk to its backend through a DaemonTransport abstraction with ZERO behavior change, so a remote transport can slot in later. New lib/src/ipc/transport.dart: DaemonTransport interface + LocalTransport (wraps today''s unix-socket connect). DaemonClient takes a transport instead of a hard-coded socket path; its reconnect loop generalizes to ''re-establish via transport''. lib/main.dart: swapIpcServer (~:119) becomes swapBackend — local binds the in-process IpcServer as today; seam ready for a remote branch. Wire protocol unchanged — JSON-lines IpcRequest/IpcResponse/IpcEvent (envelope.dart) already serialize over IPC; event stream, tail --events, events --since cursor-pull ride the same stream untouched. Verify: make test-core (IPC) + testmode skill — local path behaves identically through the seam, no regression. Depends on Phase 0 (T-330).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 13:26:25', '2026-06-12 01:04:52', NULL, 'c36521dfda73828837c32d44eb2f6a4e', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-12 01:05:38', NULL, '8cdf49e49dfbc07f6bb2ca2532cc65fc', 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).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-12 01:10:56', NULL, '0a918280afc7a04dd2327d0b96334245', 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).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-12 01:11:07', NULL, 'f02bdd147f6d8d3ee4f5d2a5a8ce2cd4', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-12 03:14:44', NULL, '62b3d0067504b4cd30f2532ce724395f', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.
Resolved 2026-06-12: user picked the no-install ssh-exec model. Artifacts landed: D-96 (footprint + D-56 reconciliation), D-97 (ssh:// URI + system-ssh BatchMode auth, Windows deferred), D-98 (remote-tool contract + batched preflight; pql/claude degrade, shell+git required), D-99 (identity keyed on (host, repo), amends D-41/D-77); Q-23 marked Resolved. The ControlMaster latency probe was waived by the decision — latency is an accepted cost of the chosen model, to be measured during T-336 implementation against a real host. T-336 expanded into concrete tickets.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-12 03:14:52', NULL, 'ab762f5d9b8cdf3c931e77ec4c1fa38d', 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 ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'SSH connection manager — ControlMaster lifecycle + exec channel', NULL, 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:00', '2026-06-12 03:15:00', NULL, '7b714c2845b3d026a1d47903d22d74dc', 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 ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'SSH connection manager — ControlMaster lifecycle + exec channel', 'The foundation module of the no-install ssh-exec execution layer (D-96/D-97). New lib/src/remote/ssh_connection.dart (Flutter-free — will be exercised by dart-test core suites like lib/src/ipc):
- SshConnection(WorkspaceRef ref): owns one ControlMaster connection per (host, port, user). Open: `ssh -o BatchMode=yes -o ControlMaster=auto -o ControlPath=<user-scope socket dir>/%C -o ControlPersist=60 -N` (or -M + background); the ControlPath dir lives in user scope next to the D-70 socket dir, never in the repo (D-93). Surface open errors verbatim (BatchMode auth failures must reach the UI with the D-97 guidance message).
- run(List<String> argv, {String? cwd, String? stdin}) → (exitCode, stdout, stderr): one exec channel over the master (`ssh <dest> -- cd <cwd> && exec ...` with proper shell quoting — write a quoteForShell helper, test it hard: spaces, quotes, $, globs).
- close(): `ssh -O exit` + cleanup. isAlive via `ssh -O check`.
- The ssh binary path is injectable (constructor param defaulting to ''ssh'') — tests use a stub executable (a shell script recording argv and replaying canned stdout/exit codes), so the full lifecycle is testable without sshd. Pattern: test/remote/ssh_connection_test.dart writes the stub into a temp dir via tester-side File I/O (dart test, no Flutter).
- Latency: measure per-run round-trip in debug logs (D-96 accepted the cost; T-330 deferred the measurement to here).
Done when: connection open/run/close lifecycle green under dart test with the stub ssh; quoting helper covered for the hostile cases; BatchMode failure surfaces a typed exception with stderr attached.', 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:00', '2026-06-12 03:15:15', NULL, '5e331bc95cdc7a9602a8381357678417', 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 ('06FBKMXSVCE98K1H76N00TYCQR', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'RemoteExecutionContext seam — subsystems stop calling Process.run/File directly', NULL, 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:21', '2026-06-12 03:15:21', NULL, '111c25c6542fe5783501f917c6ac29d1', 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 ('06FBKMXSVCE98K1H76N00TYCQR', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'RemoteExecutionContext seam — subsystems stop calling Process.run/File directly', 'The sweep half of D-96: subsystems that touch the workspace (git client, pql client, files listing/IO, search engines, editor registry) gain an ExecutionContext seam instead of bare Process.run/File/Directory, so the same subsystem code serves local and ssh-exec workspaces.
Shape: lib/src/remote/execution_context.dart (Flutter-free) —
- abstract ExecutionContext { Future<ProcResult> run(String exe, List<String> args, {String? cwd, String? stdinText}); plus the file primitives actually used: readFile/writeFile/stat/list/exists/delete (audit the real call surface first — grep Process.run + dart:io File/Directory under lib/src/{git,pql,files,search,editor,daemon}). }
- LocalExecutionContext: today''s behavior verbatim (Process.run + dart:io).
- SshExecutionContext(SshConnection) [T-398]: run → exec channel; file primitives via standard remote commands (cat/stat -c/find/test/rm; write via `cat > file` with stdin) — POSIX only per D-98.
Migration order (one subsystem per commit, zero behavior change proven by existing suites): git/operations.dart → pql/client.dart → files/listing.dart → search → editor/registry.dart. Constructor-inject the context defaulting to LocalExecutionContext so call sites don''t churn.
Watcher: the polling watcher (debounced mtime/git-status sweep emitting the same FileChange events; inotifywait opportunistic) is its own follow-up ticket once this seam exists — don''t fold it in here.
Done when: all five subsystems take an ExecutionContext, local default keeps every existing test green untouched, SshExecutionContext passes a stub-ssh suite for run + each file primitive.', 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:21', '2026-06-12 03:15:36', NULL, '621f327cb486e4a5a6bff7ab832089b6', 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 ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'remote PTY over ssh -tt for terminal + Claude panes', NULL, 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:15:41', '2026-06-12 03:15:41', NULL, 'fd76adffcac27c5db23bfb56fd70c858', 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 ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'remote PTY over ssh -tt for terminal + Claude panes', 'Interactive channels of the ssh-exec model (D-96). A remote terminal/Claude pane spawns `ssh -tt [-p port] [user@]host -- cd <cwd> && exec <cmd>` LOCALLY via the existing native PTY (posix_openpt + posix_spawn, lib/src/pty/native_pty.dart) — the local PTY wraps the ssh process, the remote side gets its own pty from -tt. So NO new PTY mechanism: implement a RemotePtySpawner that builds the ssh argv from a WorkspaceRef + command and hands it to NativePty.
- Resize: local PTY resize propagates through ssh automatically (SIGWINCH on the local pty → ssh forwards). Verify with a resize test against the stub.
- Exit/loss: ssh exiting (network drop) is a pane exit — the claude pane''s session-end status line (T-372 work) already renders that; terminal pane likewise.
- Env: CLIDE_SOCK etc. (agent_bootstrap.dart) is Phase-3 scope (T-333) — out of scope here.
- Tests: stub ssh (same harness as T-398) + the existing pty dart-test suite pattern (test/pty is serial under dart test).
Done when: a RemotePtySpawner produces correct argv (quoting via T-398''s helper), spawns through NativePty, resize + exit propagate, covered under dart test with the stub.', 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:15:41', '2026-06-12 03:15:55', NULL, 'e79889d7495024f1ab6f850a1372bda5', 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 ('06FBKN2MP35NPPK1BRDYY2M428', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'polling file watcher for remote workspaces', NULL, 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:16:00', '2026-06-12 03:16:00', NULL, '8a174958e402b500d1930ff032cb8f0d', 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 ('06FBKN2MP35NPPK1BRDYY2M428', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'polling file watcher for remote workspaces', 'D-96''s watching degrade. Remote workspaces get FileChange events from a poller, not inotify, with the SAME event shapes the local watcher (lib/src/files/watcher.dart) emits — consumers must not know the difference.
- Primary mode: debounced sweep over the ExecutionContext (T-399): `git status --porcelain -z` (tracked changes, cheap on the remote) + a `find -newer <stamp>` pass for untracked/ignored-relevant paths, on a ~2s cadence, diffed against the previous snapshot to synthesize add/modify/delete events.
- Opportunistic upgrade: if the D-98 preflight found inotifywait, hold one long-lived exec channel running `inotifywait -m -r` and translate its lines — instant events, still zero-install (inotifywait is the remote''s own tool).
- Pause the sweep while no remote workspace is open; back off (cadence x4) when the pane is unfocused/minimized to respect the round-trip budget.
- Tests: drive with a fake ExecutionContext replaying canned snapshots; assert synthesized event sequences (created/modified/deleted, rename = delete+create), debounce, and the inotifywait line-translation table.
Done when: poller emits watcher-compatible events from snapshot diffs under test, inotifywait mode translates correctly, and cadence/backoff is config-free but bounded.', 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:16:00', '2026-06-12 03:16:12', NULL, 'dbff34b497bba39c54f0bf408f54f7a8', 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 ('06FBKN4QVFVE51MY2N0CWCVXHM', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'connect preflight — remote-tool contract probe (D-98)', NULL, 'backlog', 'medium', NULL, NULL, 'D-98', '2026-06-12 03:16:18', '2026-06-12 03:16:18', NULL, '33c58407367b2c262392c6f95e8f41f0', 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 ('06FBKN4QVFVE51MY2N0CWCVXHM', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'connect preflight — remote-tool contract probe (D-98)', 'Implements D-98. On remote connect (after the SshConnection opens, before ProjectOpened fires), run ONE batched probe over the exec channel:
for t in sh git pql claude inotifywait; do printf ''%s='' "$t"; command -v "$t" >/dev/null 2>&1 && "$t" --version 2>/dev/null | head -1 || echo MISSING; done
(or equivalent single round-trip). Parse into a RemoteToolset { git: version?, pql: version?, claude: version?, inotifywait: bool }.
- shell+git MISSING → the open fails with the probe output in the error (actionable, names the host).
- pql MISSING → planning/query surfaces dark behind the D-95-style banner ("pql not found on <host> — install it there to enable tickets/decisions"); version skew vs the bundled local pql is surfaced (toast), not reconciled.
- claude MISSING → Claude pane disabled with notice; everything else live.
- inotifywait presence feeds the T-401 watcher mode pick.
- Tests: parse table from canned probe outputs (all-present, pql-missing, git-missing, weird version strings); fail-the-open path; banner gating is a later UI ticket under Phase 5 (T-335) — this ticket is the probe + model + open-gate only.
Done when: probe runs as one exec round-trip, RemoteToolset drives open-failure for missing required tools, parse covered under dart test.', 'backlog', 'medium', NULL, NULL, 'D-98', '2026-06-12 03:16:18', '2026-06-12 03:16:30', NULL, '925c2c4148eef4d6159c413dee008733', 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 ('06FB3DQEMTDHF8SV27AKAB8JHW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Fork — execution layer (expand after Phase-0 spike decides footprint)', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).
2026-06-12: footprint decided — no-install ssh-exec (D-96, user pick). This story is now the EXECUTION-LAYER UMBRELLA for that model; the agent-model branch in the description above is dead. Expanded into: T-398 (SSH connection manager — ControlMaster + exec channel, the foundation), T-399 (ExecutionContext seam sweep across git/pql/files/search/editor), T-400 (remote PTY via ssh -tt through the existing NativePty), T-401 (polling watcher), T-402 (D-98 preflight probe). Blocker graph: 399/400/402 by 398; 401 by 399. Close this story when those five are done; T-333/T-334 unblock then.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:56', '2026-06-12 03:16:59', NULL, '4fc2f8e6f5436799502a571f7bc8cce6', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-12 03:20:52', NULL, 'bcb56a3fee5350d82a45f846bb500f83', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-12 03:21:06', NULL, 'c010f4263ca39c5852ed7615dda1e94b', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-12 03:21:11', NULL, 'f7e0c94b671a3da751bf2a1359bb9eca', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` → command:panel.focus.left; `ctrl+w l` → command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` → command:dock.toggle — document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` → focus.nextPanel; `ctrl+w shift+w` → focus.previousPanel
- `ctrl+w o` → command:panel.focusMode (vim "only" — exact semantic match)
- `ctrl+w q` and `ctrl+w c` → command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset — bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-12 03:21:26', NULL, '5d1048dc893b51f245a8dbbda5f96f8c', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'db7e75ad6a5f5854fb349cf68899a8c8', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-12 03:21:43', NULL, '1a8fc46b24bcdb47326b1a1ae23ae785', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '083c644a38176907300739c60ac621e1', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml — intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists — check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary — verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later — the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only — no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-12 03:22:05', NULL, 'ed32bc2d1104dd21559d5432808327d5', 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)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '349cf7a92c07e61b94e4881d80f3b3b1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-12 03:22:28', NULL, 'f5eeef51f090bf48c73133940d5348f9', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'intercept /model in the Claude pane: arg sets model, bare shows picker', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 06:40:25', '2026-06-12 06:40:25', NULL, 'f08ac88045e15ed480efe59a127dcedc', 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 ('06FBN3VYR84023Z5XFEX9DS0S0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'shift+; opens quick-open instead of typing colon — double-Shift detector fires on chorded Shift', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-12 06:40:26', '2026-06-12 06:40:26', NULL, 'fc7bbb1351f2b1b3cc279aa466009381', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'intercept /model in the Claude pane: arg sets model, bare shows picker', 'User report: typing /model in the Claude conversation view does nothing useful — it is forwarded to the session''s stream-json stdin like a plain message, and the CLI''s interactive /model picker only exists in its own TUI. Clide must own it (same class as /clear,/resume,/fork — T-156).
Interaction design:
- `/model <name>` → set the session model directly to <name> (accept aliases like sonnet/opus and full ids).
- `/model` (bare) → show a model picker in the interaction zone — replaces the composer while open, like ToolPromptCard (D-78); list selectable via keyboard (numbers/arrows + Enter), Esc cancels back to the composer.
Implementation map (from code exploration):
- Add ''model'' to kClideOwnedCommands in lib/builtin/claude/src/slash_commands.dart and handle it in ClaudePane._send (lib/builtin/claude/src/claude_pane.dart).
- Add StreamJsonSession.setModel(String) following the setPermissionMode control_request pattern (lib/builtin/claude/src/stream_json_session.dart) — subtype set_model; optimistically merge SessionStatus(model: …) so the status bar updates.
- Model list for the bare-picker: query the CLI via the supported_models-style control request if available (verify exact subtype/shapes against the installed CLI), falling back to a static alias list.
- Picker widget swaps in via the existing pending-interaction slot in ClaudePane; reuse composer focus/draft preservation (the draft must survive the swap).
Acceptance:
- `/model sonnet` switches the live session model; status bar reflects it on the next status merge.
- bare `/model` opens the picker; choosing an entry sets the model; Esc restores the composer with the draft intact.
- `/model` is never forwarded to the session as message text.
- Unit tests in test/builtin/claude/ for the parsing (slash_commands_test.dart), the pane interception, and the picker widget.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 06:40:25', '2026-06-12 06:40:53', NULL, 'dfd74b1e6c5c14326837dfc2e3ae67fa', 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 ('06FBN3VYR84023Z5XFEX9DS0S0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'shift+; opens quick-open instead of typing colon — double-Shift detector fires on chorded Shift', 'Regression from T-341 (double-tap-modifier shortcuts, shipped in 2.4.0). User report: pressing shift+; to type a colon in the editor or the Claude composer no longer types '':'' — the quick-open finder opens instead.
Likely cause: the double-Shift tap detector counts a Shift press/release as a "tap" even when another key was chorded while Shift was held. Typing '':'' is shift-down, '';'', shift-up; two colons (or a colon shortly after any shifted character) within the tap window then reads as shift,shift → "Search Everywhere" fires and may also swallow the keystroke.
Fix: a modifier press only qualifies as a tap if NO other key goes down between the modifier''s keydown and keyup. Any chorded key must invalidate the pending tap (and reset the double-tap sequence state).
Acceptance:
- Typing `::` rapidly in the editor and in the Claude composer produces two colons, never quick-open.
- Shifted typing in general (capitals, symbols) never triggers double-tap bindings.
- Genuine double-Shift (two bare taps within the window) still opens quick-open in all four presets.
- Regression test covering chorded-Shift-then-Shift-tap sequences.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-12 06:40:26', '2026-06-12 06:40:54', NULL, 'faecb0d66ba124ee017b89ab14c3c332', 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 ('06FBN3VYR84023Z5XFEX9DS0S0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'shift+; opens quick-open instead of typing colon — double-Shift detector fires on chorded Shift', 'Regression from T-341 (double-tap-modifier shortcuts, shipped in 2.4.0). User report: pressing shift+; to type a colon in the editor or the Claude composer no longer types '':'' — the quick-open finder opens instead.
Likely cause: the double-Shift tap detector counts a Shift press/release as a "tap" even when another key was chorded while Shift was held. Typing '':'' is shift-down, '';'', shift-up; two colons (or a colon shortly after any shifted character) within the tap window then reads as shift,shift → "Search Everywhere" fires and may also swallow the keystroke.
Fix: a modifier press only qualifies as a tap if NO other key goes down between the modifier''s keydown and keyup. Any chorded key must invalidate the pending tap (and reset the double-tap sequence state).
Acceptance:
- Typing `::` rapidly in the editor and in the Claude composer produces two colons, never quick-open.
- Shifted typing in general (capitals, symbols) never triggers double-tap bindings.
- Genuine double-Shift (two bare taps within the window) still opens quick-open in all four presets.
- Regression test covering chorded-Shift-then-Shift-tap sequences.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-12 06:40:26', '2026-06-12 06:40:59', NULL, 'bfbf8ed99c6f9a9920a341d8a470a23e', 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 ('06FBN3VYR84023Z5XFEX9DS0S0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'shift+; opens quick-open instead of typing colon — double-Shift detector fires on chorded Shift', 'Regression from T-341 (double-tap-modifier shortcuts, shipped in 2.4.0). User report: pressing shift+; to type a colon in the editor or the Claude composer no longer types '':'' — the quick-open finder opens instead.
Likely cause: the double-Shift tap detector counts a Shift press/release as a "tap" even when another key was chorded while Shift was held. Typing '':'' is shift-down, '';'', shift-up; two colons (or a colon shortly after any shifted character) within the tap window then reads as shift,shift → "Search Everywhere" fires and may also swallow the keystroke.
Fix: a modifier press only qualifies as a tap if NO other key goes down between the modifier''s keydown and keyup. Any chorded key must invalidate the pending tap (and reset the double-tap sequence state).
Acceptance:
- Typing `::` rapidly in the editor and in the Claude composer produces two colons, never quick-open.
- Shifted typing in general (capitals, symbols) never triggers double-tap bindings.
- Genuine double-Shift (two bare taps within the window) still opens quick-open in all four presets.
- Regression test covering chorded-Shift-then-Shift-tap sequences.', 'done', 'high', NULL, NULL, NULL, '2026-06-12 06:40:26', '2026-06-12 06:49:24', NULL, '7a26c6d1ae0aa3086ef337aa1640f799', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'intercept /model in the Claude pane: arg sets model, bare shows picker', 'User report: typing /model in the Claude conversation view does nothing useful — it is forwarded to the session''s stream-json stdin like a plain message, and the CLI''s interactive /model picker only exists in its own TUI. Clide must own it (same class as /clear,/resume,/fork — T-156).
Interaction design:
- `/model <name>` → set the session model directly to <name> (accept aliases like sonnet/opus and full ids).
- `/model` (bare) → show a model picker in the interaction zone — replaces the composer while open, like ToolPromptCard (D-78); list selectable via keyboard (numbers/arrows + Enter), Esc cancels back to the composer.
Implementation map (from code exploration):
- Add ''model'' to kClideOwnedCommands in lib/builtin/claude/src/slash_commands.dart and handle it in ClaudePane._send (lib/builtin/claude/src/claude_pane.dart).
- Add StreamJsonSession.setModel(String) following the setPermissionMode control_request pattern (lib/builtin/claude/src/stream_json_session.dart) — subtype set_model; optimistically merge SessionStatus(model: …) so the status bar updates.
- Model list for the bare-picker: query the CLI via the supported_models-style control request if available (verify exact subtype/shapes against the installed CLI), falling back to a static alias list.
- Picker widget swaps in via the existing pending-interaction slot in ClaudePane; reuse composer focus/draft preservation (the draft must survive the swap).
Acceptance:
- `/model sonnet` switches the live session model; status bar reflects it on the next status merge.
- bare `/model` opens the picker; choosing an entry sets the model; Esc restores the composer with the draft intact.
- `/model` is never forwarded to the session as message text.
- Unit tests in test/builtin/claude/ for the parsing (slash_commands_test.dart), the pane interception, and the picker widget.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 06:40:25', '2026-06-12 06:50:01', NULL, 'cba563b9c69c2bb0033b33bd32895059', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'intercept /model in the Claude pane: arg sets model, bare shows picker', 'User report: typing /model in the Claude conversation view does nothing useful — it is forwarded to the session''s stream-json stdin like a plain message, and the CLI''s interactive /model picker only exists in its own TUI. Clide must own it (same class as /clear,/resume,/fork — T-156).
Interaction design:
- `/model <name>` → set the session model directly to <name> (accept aliases like sonnet/opus and full ids).
- `/model` (bare) → show a model picker in the interaction zone — replaces the composer while open, like ToolPromptCard (D-78); list selectable via keyboard (numbers/arrows + Enter), Esc cancels back to the composer.
Implementation map (from code exploration):
- Add ''model'' to kClideOwnedCommands in lib/builtin/claude/src/slash_commands.dart and handle it in ClaudePane._send (lib/builtin/claude/src/claude_pane.dart).
- Add StreamJsonSession.setModel(String) following the setPermissionMode control_request pattern (lib/builtin/claude/src/stream_json_session.dart) — subtype set_model; optimistically merge SessionStatus(model: …) so the status bar updates.
- Model list for the bare-picker: query the CLI via the supported_models-style control request if available (verify exact subtype/shapes against the installed CLI), falling back to a static alias list.
- Picker widget swaps in via the existing pending-interaction slot in ClaudePane; reuse composer focus/draft preservation (the draft must survive the swap).
Acceptance:
- `/model sonnet` switches the live session model; status bar reflects it on the next status merge.
- bare `/model` opens the picker; choosing an entry sets the model; Esc restores the composer with the draft intact.
- `/model` is never forwarded to the session as message text.
- Unit tests in test/builtin/claude/ for the parsing (slash_commands_test.dart), the pane interception, and the picker widget.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 06:40:25', '2026-06-12 07:04:36', NULL, 'b6d3ad788489ec073a2fdc56d0d02fe9', 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 ('06FBP3EZC7AJANXZVF3D91QYWM', 'epic', NULL, 'Claude sidebar — power control panel + full harness-command capture', 'PROBLEM. clide runs the Claude CLI headless (stream-json). The CLI''s interactive TUI commands don''t exist there: a typed /effort (observed live, 2026-06-12) is forwarded and the CLI answers with a raw system/local_command error — ''/effort isn''t available in this environment.'' Today clide owns only {clear, resume, fork, model} (slash_commands.dart kClideOwnedCommands); every other TUI-only builtin (/permissions /status /cost /help /config /memory /mcp /agents /usage-adjacent…) falls through to that raw error. Meanwhile the sidebar''s Config/Activity tabs (T-182/T-183) are mostly read-only rows — the lone interactions are the permission badge and T-408''s model picker (which lives in the pane''s interaction zone, not the sidebar).
EVIDENCE / GROUNDING. clide already caches the CLI initialize handshake per version (~/.config/clide/claude/init-<version>.json, T-151). The advertised slash_commands list = skills + a small headless-capable builtin set (clear, compact, context, init, review, security-review, usage, …). TUI-only builtins are NOT advertised — that''s the authoritative signal for what forwards safely. Current CLI 2.1.175.
DESIGN — three layers:
1. ROUTING TABLE (one source of truth, replaces kClideOwnedCommands): every command token → forward (advertised by the live probe: skills, compact, context, …) | owned (clide implements natively: clear/resume/fork/model today; effort, permissions, config, memory, mcp, agents, todos, status, help as they land) | unavailable (known TUI-only with no equivalent: doctor, login, logout, terminal-setup, ide, exit → friendly explanation card, never the CLI''s raw error). Probe-driven so new CLI versions stay current without code changes.
2. REACTIVE CATCH-ALL: any system/local_command response whose stdout matches "isn''t available in this environment" renders as a muted hint card (pointing at the matching sidebar control when one exists) instead of raw harness output. This captures FUTURE unknown commands automatically — the guarantee ''nothing surfaces as a raw harness error'', not just a hand-maintained list.
3. SIDEBAR = POWER CONTROL PANEL (D-6 parity): every owned command gets a sidebar interaction and every sidebar control is reachable as a slash command. Config tab gains inline pickers (model T-408, permission T-275, effort new); Activity tab gains session controls (clear/compact/fork/resume) and a usage/cost block (/usage IS advertised in stream-json per the probes — revisit T-158''s upstream blocker).
Child stories carry the implementation slices. Refs: slash_commands.dart, claude_pane._send, claude_meta_sidebar.dart (+T-395 split), claude_config.dart probe, T-408 set_model spike pattern.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-12 08:58:29', '2026-06-12 08:58:29', NULL, 'a7e99299f177abc8dc9c3a9639ecffb2', 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 ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Slash-command routing table + graceful TUI-only capture', 'Replace kClideOwnedCommands (a bare Set) with a declarative routing table: token → forward | owned | unavailable. ''forward'' is probe-driven (the live initialize handshake''s slash_commands — skills + headless builtins); ''owned'' dispatches to clide implementations (clear/resume/fork/model today, growing per sibling stories); ''unavailable'' is a curated catalog of known TUI-only commands (doctor, login, logout, terminal-setup, ide, exit, vim, statusline, output-style, …) that get a friendly card explaining the clide equivalent — never forwarded.
Plus the reactive catch-all: a system/local_command event whose stdout matches "isn''t available in this environment" renders as a muted hint card (suggesting the sidebar control when the routing table maps one) instead of raw CLI error text. This auto-captures future/unknown TUI commands without catalog maintenance.
Where: slash_commands.dart (table), claude_pane._send (dispatch), stream_json_session/transcript handling for the local_command catch, conversation card for the hint. Tests: routing per class; the catch-all renders the hint card; advertised-but-unknown forwards untouched; typeahead lists owned + advertised, not ''unavailable'' ones (or lists them greyed).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-12 08:58:42', '2026-06-12 08:58:42', NULL, 'cdb02291f657200f7d83ecc05ea7e927', 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 ('06FBP3J7TXMG0F9E2WQDENPVJG', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Own /effort: control-request spike + arg/picker UX', 'Mirror T-408''s /model treatment for /effort (thinking-effort level). SPIKE FIRST (same method as the T-408 set_model spike against claude 2.1.175): does the stream-json control channel accept a set_effort-style control_request subtype? Probe candidate subtypes alongside set_permission_mode/set_model; document the result in the ticket. If no control subtype exists, decide a fallback explicitly — settings-file write + session restart, or declare it unavailable-with-explanation — don''t fake it.
UX when supported: ''/effort <level>'' sets directly (optimistic status merge, toast rollback on rejection, exactly the T-408 pattern); bare ''/effort'' swaps a picker card into the interaction zone (D-78). Surface the current effort in SessionStatus + the sidebar config row, and as an inline selector per the power-panel story. Levels per CLI docs (e.g. low/medium/high; verify against the CLI version).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:58:55', '2026-06-12 08:58:55', NULL, 'd81257667d1bdaaca9652bf59b8fd9ce', 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 ('06FBP3KRWM65MD3DS251NN9YX0', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Open-in-clide command family: /permissions /config /memory /mcp /agents /todos /status /help', 'The ''owned'' routes that map a TUI command to an existing clide surface — thin per-command glue, one story. /permissions → open the permission-mode picker (T-275 control). /config → focus the Claude sidebar Config tab (T-183). /memory → open CLAUDE.md (and memory files) in the editor. /mcp, /agents, /hooks → their Config-tab sections. /todos → focus the task dock (T-308). /status → focus the sidebar Activity tab. /help → a clide help card listing owned commands + advertised skills (NOT the CLI''s TUI help).
Each lands in the routing table (T-411) as ''owned''; D-6 parity notes: these are also the navigation targets the catch-all hint card (T-411) points at. Keep each handler a few lines — navigation/publish on the existing MessageBus + panel APIs, no new surfaces. Tests: each command routes to its surface (message published / tab activated), none forwards to the session.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:08', '2026-06-12 08:59:08', NULL, '3ed38c73ad486d399687cbe92af438d1', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Config tab → interactive controls (model / effort / permission inline)', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6).
Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, 'ed84313d732ca81061a3db91b0bd0b0e', 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 ('06FBP3P91QQQDT5J50F52FPCKM', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Activity tab: session controls + usage block (revisit T-158)', 'Two additions to the sidebar Activity tab. (1) Session controls row: clear / compact / fork / resume as icon buttons driving the exact same handlers as the slash commands (claude_pane._send routes) — D-6 parity for the session-mutating set. Confirm-on-destructive (/clear) via the existing dialog pattern.
(2) Usage/cost block: the version-keyed probes show /usage IS advertised in stream-json (init-2.1.16x.json) — probe what forwarded /usage returns in headless mode and, if parseable, render plan usage alongside the existing cost/context data from result events (T-168 fields already in SessionStatus). This revisits T-158 ''blocked on upstream'' — if the output is unusable, document that in T-158 and keep the cost/context-only block. Link T-158 either way.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '5dc16e95609379e3360c9e59f97ab11e', 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 ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Slash-command routing table + graceful TUI-only capture', 'Replace kClideOwnedCommands (a bare Set) with a declarative routing table: token → forward | owned | unavailable. ''forward'' is probe-driven (the live initialize handshake''s slash_commands — skills + headless builtins); ''owned'' dispatches to clide implementations (clear/resume/fork/model today, growing per sibling stories); ''unavailable'' is a curated catalog of known TUI-only commands (doctor, login, logout, terminal-setup, ide, exit, vim, statusline, output-style, …) that get a friendly card explaining the clide equivalent — never forwarded.
Plus the reactive catch-all: a system/local_command event whose stdout matches "isn''t available in this environment" renders as a muted hint card (suggesting the sidebar control when the routing table maps one) instead of raw CLI error text. This auto-captures future/unknown TUI commands without catalog maintenance.
Where: slash_commands.dart (table), claude_pane._send (dispatch), stream_json_session/transcript handling for the local_command catch, conversation card for the hint. Tests: routing per class; the catch-all renders the hint card; advertised-but-unknown forwards untouched; typeahead lists owned + advertised, not ''unavailable'' ones (or lists them greyed).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-12 08:58:42', '2026-06-12 10:09:07', NULL, '74e76e9dbb0f23fadff3f85af53388f8', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Config tab → interactive controls (model / effort / permission inline)', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6).
Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.
STYLING PASS (user, 2026-06-12): the Claude sidepanel looks bland and the font is
small. While making the Config tab interactive, also do a visual polish pass over
the whole Claude sidebar (Activity/Team/Config): bump the row/label typography to
the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing,
accent marks per ui-design tokens), and make the controls feel like controls.
Treat ui-design skill as the reference for token/type choices.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 10:24:43', NULL, '99fac60083c6408fd148b995ee85cc15', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, '579c8318c3c217ae5bbbc90661dbe917', 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 ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Slash-command routing table + graceful TUI-only capture', 'Replace kClideOwnedCommands (a bare Set) with a declarative routing table: token → forward | owned | unavailable. ''forward'' is probe-driven (the live initialize handshake''s slash_commands — skills + headless builtins); ''owned'' dispatches to clide implementations (clear/resume/fork/model today, growing per sibling stories); ''unavailable'' is a curated catalog of known TUI-only commands (doctor, login, logout, terminal-setup, ide, exit, vim, statusline, output-style, …) that get a friendly card explaining the clide equivalent — never forwarded.
Plus the reactive catch-all: a system/local_command event whose stdout matches "isn''t available in this environment" renders as a muted hint card (suggesting the sidebar control when the routing table maps one) instead of raw CLI error text. This auto-captures future/unknown TUI commands without catalog maintenance.
Where: slash_commands.dart (table), claude_pane._send (dispatch), stream_json_session/transcript handling for the local_command catch, conversation card for the hint. Tests: routing per class; the catch-all renders the hint card; advertised-but-unknown forwards untouched; typeahead lists owned + advertised, not ''unavailable'' ones (or lists them greyed).', 'done', 'high', NULL, NULL, NULL, '2026-06-12 08:58:42', '2026-06-12 10:25:53', NULL, 'd1aa3a143b9db8758ca20ba5197d9332', 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 ('06FBP3J7TXMG0F9E2WQDENPVJG', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Own /effort: control-request spike + arg/picker UX', 'Mirror T-408''s /model treatment for /effort (thinking-effort level). SPIKE FIRST (same method as the T-408 set_model spike against claude 2.1.175): does the stream-json control channel accept a set_effort-style control_request subtype? Probe candidate subtypes alongside set_permission_mode/set_model; document the result in the ticket. If no control subtype exists, decide a fallback explicitly — settings-file write + session restart, or declare it unavailable-with-explanation — don''t fake it.
UX when supported: ''/effort <level>'' sets directly (optimistic status merge, toast rollback on rejection, exactly the T-408 pattern); bare ''/effort'' swaps a picker card into the interaction zone (D-78). Surface the current effort in SessionStatus + the sidebar config row, and as an inline selector per the power-panel story. Levels per CLI docs (e.g. low/medium/high; verify against the CLI version).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:58:55', '2026-06-12 10:25:53', NULL, 'd0b0dbfec1edcdd4ffe78372f440cf1b', 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 ('06FBP3J7TXMG0F9E2WQDENPVJG', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Own /effort: control-request spike + arg/picker UX', 'Mirror T-408''s /model treatment for /effort (thinking-effort level). SPIKE FIRST (same method as the T-408 set_model spike against claude 2.1.175): does the stream-json control channel accept a set_effort-style control_request subtype? Probe candidate subtypes alongside set_permission_mode/set_model; document the result in the ticket. If no control subtype exists, decide a fallback explicitly — settings-file write + session restart, or declare it unavailable-with-explanation — don''t fake it.
UX when supported: ''/effort <level>'' sets directly (optimistic status merge, toast rollback on rejection, exactly the T-408 pattern); bare ''/effort'' swaps a picker card into the interaction zone (D-78). Surface the current effort in SessionStatus + the sidebar config row, and as an inline selector per the power-panel story. Levels per CLI docs (e.g. low/medium/high; verify against the CLI version).', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 08:58:55', '2026-06-12 10:56:52', NULL, '448346b2ab034e4560d9b9589e318344', 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 ('06FBP3KRWM65MD3DS251NN9YX0', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Open-in-clide command family: /permissions /config /memory /mcp /agents /todos /status /help', 'The ''owned'' routes that map a TUI command to an existing clide surface — thin per-command glue, one story. /permissions → open the permission-mode picker (T-275 control). /config → focus the Claude sidebar Config tab (T-183). /memory → open CLAUDE.md (and memory files) in the editor. /mcp, /agents, /hooks → their Config-tab sections. /todos → focus the task dock (T-308). /status → focus the sidebar Activity tab. /help → a clide help card listing owned commands + advertised skills (NOT the CLI''s TUI help).
Each lands in the routing table (T-411) as ''owned''; D-6 parity notes: these are also the navigation targets the catch-all hint card (T-411) points at. Keep each handler a few lines — navigation/publish on the existing MessageBus + panel APIs, no new surfaces. Tests: each command routes to its surface (message published / tab activated), none forwards to the session.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:08', '2026-06-12 10:56:52', NULL, '395cba57154e147b6a35212e23e2d17e', 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 ('06FBP3KRWM65MD3DS251NN9YX0', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Open-in-clide command family: /permissions /config /memory /mcp /agents /todos /status /help', 'The ''owned'' routes that map a TUI command to an existing clide surface — thin per-command glue, one story. /permissions → open the permission-mode picker (T-275 control). /config → focus the Claude sidebar Config tab (T-183). /memory → open CLAUDE.md (and memory files) in the editor. /mcp, /agents, /hooks → their Config-tab sections. /todos → focus the task dock (T-308). /status → focus the sidebar Activity tab. /help → a clide help card listing owned commands + advertised skills (NOT the CLI''s TUI help).
Each lands in the routing table (T-411) as ''owned''; D-6 parity notes: these are also the navigation targets the catch-all hint card (T-411) points at. Keep each handler a few lines — navigation/publish on the existing MessageBus + panel APIs, no new surfaces. Tests: each command routes to its surface (message published / tab activated), none forwards to the session.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:08', '2026-06-12 11:06:36', NULL, 'be2c0f83cfd0045e2ed7f5f2fdd38298', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Config tab → interactive controls (model / effort / permission inline)', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6).
Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.
STYLING PASS (user, 2026-06-12): the Claude sidepanel looks bland and the font is
small. While making the Config tab interactive, also do a visual polish pass over
the whole Claude sidebar (Activity/Team/Config): bump the row/label typography to
the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing,
accent marks per ui-design tokens), and make the controls feel like controls.
Treat ui-design skill as the reference for token/type choices.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 11:06:36', NULL, 'ed8b6c209963a835c66bb5e28b06eda1', 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 ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Status bar: inconsistent fonts, sizes, tokens, and vertical alignment across items', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 11:22:15', '2026-06-12 11:22:15', NULL, '5abf1d2c781c21eae8bc965cd1f3cbe2', 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 ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Status bar: inconsistent fonts, sizes, tokens, and vertical alignment across items', 'The bottom central status bar mixes fonts, sizes, color tokens, and vertical alignment across its items, so the bar reads as several unrelated strips instead of one cohesive surface (user screenshot, 2026-06-12).
**Observed inconsistencies:**
- **Claude pane context status** (`lib/builtin/claude/src/claude_pane.dart:131` `_statusWidget`, `_ModeBadge` at line 816): `clideFontSmall` (12) + `clideMonoFamily` — the only 12px mono run in the bar.
- **Git branch item** (`lib/builtin/git/src/git_status_item.dart:78`): `clideFontCaption` (14), default UI face (Josefin Sans w300) — sits directly next to the 12px mono Claude segment.
- **Output dock item** (`lib/builtin/output/src/dock_status_item.dart:77`): `clideFontCaption` + `tokens.globalForeground` instead of `tokens.statusBarForeground`; also embeds `▼`/`▲` glyphs as text rather than a `ClideIcon`.
- **IPC tool status** (`lib/builtin/ipc_status/src/status_item.dart:45`) and **theme switcher** (`lib/builtin/theme_picker/src/theme_status_item.dart:68`): `clideFontCaption` + `statusBarForeground` — these two agree with each other but not with the Claude segment.
- **Vertical alignment:** `PaneContextStatusItem` (`lib/builtin/claude/src/pane_context_status.dart:34`) clamps its content in a fixed 16px `SizedBox` inside `ClideMarquee`, while `StatusbarHost` (`lib/src/shell/layout.dart:251`) centers other items via `CrossAxisAlignment.center` on the full bar height — the differing font sizes/line metrics make the Claude run sit visibly off-center relative to its neighbours.
**Direction (per /ui-design skill):**
- Typography rule says status bar text is `clideFontCaption` (14); mono (`clideFontMono`/`clideMonoFamily`) is reserved for code/paths/IDs. Decide one type treatment for the bar — likely caption + UI face for labels, mono only for genuinely code-like fragments (e.g. `633k / 1.0M ctx`) if at all — and apply it to every item.
- All status bar items should use `tokens.statusBarForeground` (muted variant: `globalTextMuted`) — fix the `globalForeground` borrow in the output dock item.
- Ensure every item centers in the bar''s vertical space: same slot height strategy (or none) across items so baselines align; review the fixed `_slotHeight = 16` in `pane_context_status.dart` against the bar''s `statusHeight`.
- Separator `·` styling (claude_pane.dart:136) should be a shared affordance if other items adopt segmented content.
Acceptance: one font family/size/token scheme across all five status items; all items visually centered in the bar''s vertical space; no `globalForeground` borrows; goldens updated.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 11:22:15', '2026-06-12 11:23:21', NULL, 'a558e4c5edebc1aa89665bc1c35a6b38', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Config tab → interactive controls (model / effort / permission inline)', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6).
Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.
STYLING PASS (user, 2026-06-12): the Claude sidepanel looks bland and the font is
small. While making the Config tab interactive, also do a visual polish pass over
the whole Claude sidebar (Activity/Team/Config): bump the row/label typography to
the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing,
accent marks per ui-design tokens), and make the controls feel like controls.
Treat ui-design skill as the reference for token/type choices.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 11:26:14', NULL, '069271bbde02f9624f80d8703c09a183', 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 ('06FBQ595H08JFTRFSR90GSZQ0G', 'task', '06FBP3EZC7AJANXZVF3D91QYWM', 'Claude sidebar tab robot icon in claude orange', 'User request (2026-06-12, try-it-out): tint the Claude sidebar tab''s robot icon with the Claude accent (#D97757, claudeAccent) so the Claude surface is identifiable at a glance in the icon rail. Nominative use per the licenses.yaml trademark note (marks Claude''s own panel, not clide branding). If the TabContribution/icon-rail doesn''t support per-tab colour, add an optional iconColor to the contribution and honour it in the rail (active/inactive states: keep the accent when active, mute toward the normal inactive tone when not).', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '63c89f03c45308ed5d9fbc78656c0941', 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 ('06FBP3EZC7AJANXZVF3D91QYWM', 'epic', NULL, 'Claude sidebar — power control panel + full harness-command capture', 'PROBLEM. clide runs the Claude CLI headless (stream-json). The CLI''s interactive TUI commands don''t exist there: a typed /effort (observed live, 2026-06-12) is forwarded and the CLI answers with a raw system/local_command error — ''/effort isn''t available in this environment.'' Today clide owns only {clear, resume, fork, model} (slash_commands.dart kClideOwnedCommands); every other TUI-only builtin (/permissions /status /cost /help /config /memory /mcp /agents /usage-adjacent…) falls through to that raw error. Meanwhile the sidebar''s Config/Activity tabs (T-182/T-183) are mostly read-only rows — the lone interactions are the permission badge and T-408''s model picker (which lives in the pane''s interaction zone, not the sidebar).
EVIDENCE / GROUNDING. clide already caches the CLI initialize handshake per version (~/.config/clide/claude/init-<version>.json, T-151). The advertised slash_commands list = skills + a small headless-capable builtin set (clear, compact, context, init, review, security-review, usage, …). TUI-only builtins are NOT advertised — that''s the authoritative signal for what forwards safely. Current CLI 2.1.175.
DESIGN — three layers:
1. ROUTING TABLE (one source of truth, replaces kClideOwnedCommands): every command token → forward (advertised by the live probe: skills, compact, context, …) | owned (clide implements natively: clear/resume/fork/model today; effort, permissions, config, memory, mcp, agents, todos, status, help as they land) | unavailable (known TUI-only with no equivalent: doctor, login, logout, terminal-setup, ide, exit → friendly explanation card, never the CLI''s raw error). Probe-driven so new CLI versions stay current without code changes.
2. REACTIVE CATCH-ALL: any system/local_command response whose stdout matches "isn''t available in this environment" renders as a muted hint card (pointing at the matching sidebar control when one exists) instead of raw harness output. This captures FUTURE unknown commands automatically — the guarantee ''nothing surfaces as a raw harness error'', not just a hand-maintained list.
3. SIDEBAR = POWER CONTROL PANEL (D-6 parity): every owned command gets a sidebar interaction and every sidebar control is reachable as a slash command. Config tab gains inline pickers (model T-408, permission T-275, effort new); Activity tab gains session controls (clear/compact/fork/resume) and a usage/cost block (/usage IS advertised in stream-json per the probes — revisit T-158''s upstream blocker).
Child stories carry the implementation slices. Refs: slash_commands.dart, claude_pane._send, claude_meta_sidebar.dart (+T-395 split), claude_config.dart probe, T-408 set_model spike pattern.', 'ready', 'high', NULL, NULL, NULL, '2026-06-12 08:58:29', '2026-06-12 11:28:23', NULL, '8a1878859ad8d93af006c18f5bfbe8ea', 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 ('06FBP3P91QQQDT5J50F52FPCKM', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Activity tab: session controls + usage block (revisit T-158)', 'Two additions to the sidebar Activity tab. (1) Session controls row: clear / compact / fork / resume as icon buttons driving the exact same handlers as the slash commands (claude_pane._send routes) — D-6 parity for the session-mutating set. Confirm-on-destructive (/clear) via the existing dialog pattern.
(2) Usage/cost block: the version-keyed probes show /usage IS advertised in stream-json (init-2.1.16x.json) — probe what forwarded /usage returns in headless mode and, if parseable, render plan usage alongside the existing cost/context data from result events (T-168 fields already in SessionStatus). This revisits T-158 ''blocked on upstream'' — if the output is unusable, document that in T-158 and keep the cost/context-only block. Link T-158 either way.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 11:28:28', NULL, 'b98c9924f0cf669ee87b094291432afb', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 11:28:32', NULL, '07318aee4082e6af072c15605745a41d', 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 ('06FBQ595H08JFTRFSR90GSZQ0G', 'task', '06FBP3EZC7AJANXZVF3D91QYWM', 'Claude sidebar tab robot icon in claude orange', 'User request (2026-06-12, try-it-out): tint the Claude sidebar tab''s robot icon with the Claude accent (#D97757, claudeAccent) so the Claude surface is identifiable at a glance in the icon rail. Nominative use per the licenses.yaml trademark note (marks Claude''s own panel, not clide branding). If the TabContribution/icon-rail doesn''t support per-tab colour, add an optional iconColor to the contribution and honour it in the rail (active/inactive states: keep the accent when active, mute toward the normal inactive tone when not).', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 11:26:14', '2026-06-12 11:28:38', NULL, 'bffe2b9dfafc4a13412ceb5c05700915', 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 ('06FBQ595H08JFTRFSR90GSZQ0G', 'task', '06FBP3EZC7AJANXZVF3D91QYWM', 'Claude sidebar tab robot icon in claude orange', 'User request (2026-06-12, try-it-out): tint the Claude sidebar tab''s robot icon with the Claude accent (#D97757, claudeAccent) so the Claude surface is identifiable at a glance in the icon rail. Nominative use per the licenses.yaml trademark note (marks Claude''s own panel, not clide branding). If the TabContribution/icon-rail doesn''t support per-tab colour, add an optional iconColor to the contribution and honour it in the rail (active/inactive states: keep the accent when active, mute toward the normal inactive tone when not).', 'done', 'low', NULL, NULL, NULL, '2026-06-12 11:26:14', '2026-06-12 11:30:22', NULL, '2f7fd997294bca6437808ba784624c47', 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 ('06FBP3P91QQQDT5J50F52FPCKM', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Activity tab: session controls + usage block (revisit T-158)', 'Two additions to the sidebar Activity tab. (1) Session controls row: clear / compact / fork / resume as icon buttons driving the exact same handlers as the slash commands (claude_pane._send routes) — D-6 parity for the session-mutating set. Confirm-on-destructive (/clear) via the existing dialog pattern.
(2) Usage/cost block: the version-keyed probes show /usage IS advertised in stream-json (init-2.1.16x.json) — probe what forwarded /usage returns in headless mode and, if parseable, render plan usage alongside the existing cost/context data from result events (T-168 fields already in SessionStatus). This revisits T-158 ''blocked on upstream'' — if the output is unusable, document that in T-158 and keep the cost/context-only block. Link T-158 either way.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 11:30:55', NULL, 'ea7652bcef396769e394a0d436920d8e', 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 ('06FBP3P91QQQDT5J50F52FPCKM', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Activity tab: session controls + usage block (revisit T-158)', 'Two additions to the sidebar Activity tab. (1) Session controls row: clear / compact / fork / resume as icon buttons driving the exact same handlers as the slash commands (claude_pane._send routes) — D-6 parity for the session-mutating set. Confirm-on-destructive (/clear) via the existing dialog pattern.
(2) Usage/cost block: the version-keyed probes show /usage IS advertised in stream-json (init-2.1.16x.json) — probe what forwarded /usage returns in headless mode and, if parseable, render plan usage alongside the existing cost/context data from result events (T-168 fields already in SessionStatus). This revisits T-158 ''blocked on upstream'' — if the output is unusable, document that in T-158 and keep the cost/context-only block. Link T-158 either way.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 08:59:28', '2026-06-12 12:04:55', NULL, '1b7b1893bbb9ac844715880b7afa6c64', 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 ('06FB0TNQM62FKQQD0B9B80PFY4', 'task', NULL, 'Team sidebar: account/team token budget (blocked on upstream)', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''.
2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path).
UNBLOCKED (2026-06-12, T-415): probed claude 2.1.175 — a forwarded /usage IS
answered headless in stream-json (free, num_turns 0) with parseable text
(session %, week % all-models, week % Sonnet). The Activity tab now renders it
via parseUsageText + a user-initiated refresh control. Remaining scope for this
ticket would be per-member/team budget split, if still wanted.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-12 12:11:28', NULL, '98e11d00c142b8377584e35a45b39cdd', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async — today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:22:42', NULL, '41ff693db584a23f2e5938f873384c2b', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async — today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:22:45', NULL, 'fad55deb0db50c438acb9efe72c6e76c', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async — today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:52:31', NULL, 'b810aebc9678105a6e4e3628810172cb', 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 ('06FBP3EZC7AJANXZVF3D91QYWM', 'epic', NULL, 'Claude sidebar — power control panel + full harness-command capture', 'PROBLEM. clide runs the Claude CLI headless (stream-json). The CLI''s interactive TUI commands don''t exist there: a typed /effort (observed live, 2026-06-12) is forwarded and the CLI answers with a raw system/local_command error — ''/effort isn''t available in this environment.'' Today clide owns only {clear, resume, fork, model} (slash_commands.dart kClideOwnedCommands); every other TUI-only builtin (/permissions /status /cost /help /config /memory /mcp /agents /usage-adjacent…) falls through to that raw error. Meanwhile the sidebar''s Config/Activity tabs (T-182/T-183) are mostly read-only rows — the lone interactions are the permission badge and T-408''s model picker (which lives in the pane''s interaction zone, not the sidebar).
EVIDENCE / GROUNDING. clide already caches the CLI initialize handshake per version (~/.config/clide/claude/init-<version>.json, T-151). The advertised slash_commands list = skills + a small headless-capable builtin set (clear, compact, context, init, review, security-review, usage, …). TUI-only builtins are NOT advertised — that''s the authoritative signal for what forwards safely. Current CLI 2.1.175.
DESIGN — three layers:
1. ROUTING TABLE (one source of truth, replaces kClideOwnedCommands): every command token → forward (advertised by the live probe: skills, compact, context, …) | owned (clide implements natively: clear/resume/fork/model today; effort, permissions, config, memory, mcp, agents, todos, status, help as they land) | unavailable (known TUI-only with no equivalent: doctor, login, logout, terminal-setup, ide, exit → friendly explanation card, never the CLI''s raw error). Probe-driven so new CLI versions stay current without code changes.
2. REACTIVE CATCH-ALL: any system/local_command response whose stdout matches "isn''t available in this environment" renders as a muted hint card (pointing at the matching sidebar control when one exists) instead of raw harness output. This captures FUTURE unknown commands automatically — the guarantee ''nothing surfaces as a raw harness error'', not just a hand-maintained list.
3. SIDEBAR = POWER CONTROL PANEL (D-6 parity): every owned command gets a sidebar interaction and every sidebar control is reachable as a slash command. Config tab gains inline pickers (model T-408, permission T-275, effort new); Activity tab gains session controls (clear/compact/fork/resume) and a usage/cost block (/usage IS advertised in stream-json per the probes — revisit T-158''s upstream blocker).
Child stories carry the implementation slices. Refs: slash_commands.dart, claude_pane._send, claude_meta_sidebar.dart (+T-395 split), claude_config.dart probe, T-408 set_model spike pattern.', 'done', 'high', NULL, NULL, NULL, '2026-06-12 08:58:29', '2026-06-12 19:44:20', NULL, 'ab48816b1f3007884c4b29ac3acd7104', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-12 19:54:32', NULL, '06a0774cf5f423cb3a7b357a34d72018', 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 ('06FBTTMGKSYMTF8M1KQWTG774W', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Workflow card: keep run details visible while collapsed', 'Follow-up polish on the T-416 workflow run card. TODAY: when the workflow collapser is collapsed it shows only the header ticker — label ''workflow'', the run name, the done/total agent counter, and the status spinner. The per-agent rows (label + model + state glyph) and the ''usage'' line (tokens · duration) only appear once expanded.
WANT (user, 2026-06-12, with screenshot): for a workflow card, keep that run detail ALWAYS visible — even collapsed — so progress is glanceable without expanding. From the screenshot the always-on info is the agent rows (each agent''s label, model, spinner/check state) and the usage line (e.g. ''104725 tokens · 57302 ms'').
SCOPE: workflow cards only — other ClideCollapserCard users keep their current collapsed ticker. Live-updates as the run progresses; collapse still toggles any heavier detail (e.g. the script segment) if we choose to keep some behind the caret.
DESIGN NOTE: ClideCollapserCard currently renders ONLY the ticker row when collapsed (clide_collapser_card.dart: _expanded ? _expandedFrame : _tickerRow) — there is no ''persistent preview'' slot. Two options: (a) the workflow card stops relying on the collapser to hide the agent rows and instead renders an always-visible mini-panel (agent rows + usage) with a collapser beneath it for the script/extras; or (b) extend ClideCollapserCard with an optional always-visible preview slot under the header. Prefer (a) unless other cards would reuse (b).
FILES: lib/builtin/claude/src/conversation_view.dart (_workflowCard / _workflowBody / _workflowAgentRow); possibly lib/widgets/src/clide_collapser_card.dart if going with option (b). Tests: test/builtin/claude/conversation_view_test.dart (assert agent rows + usage render while collapsed).
ACCEPTANCE: a collapsed workflow card shows each agent row with its live state and the usage line; the counter/spinner still summarize; non-workflow collapsers are unchanged.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, '92bba4143565e2e3626824eacd3d4793', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` → command:panel.focus.left; `ctrl+w l` → command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` → command:dock.toggle — document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` → focus.nextPanel; `ctrl+w shift+w` → focus.previousPanel
- `ctrl+w o` → command:panel.focusMode (vim "only" — exact semantic match)
- `ctrl+w q` and `ctrl+w c` → command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset — bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands — but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent → Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings — it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`→panel.focus.left, `ctrl+w l`→panel.focus.right, `ctrl+w j`→dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`→focus.nextPanel, `ctrl+w shift+w`→focus.previousPanel, `ctrl+w o`→panel.focusMode, `ctrl+w q`/`ctrl+w c`→editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains — editor_presets_test.dart `ctrl+w → editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey — single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords — decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own — non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface — coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live — a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately — confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists — intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t — ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-12 20:03:16', NULL, '504a560e8f4d670770c41d4b7767a7a7', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap — only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle — so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`→workspace.tab.next and `g shift+t`→workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntent→commands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it — but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`→workspace.tab.next, `g shift+t`→workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` — same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now — needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-12 20:03:43', NULL, '607534e0a940d10eb7121be4265b9c80', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml — intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists — check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary — verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later — the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only — no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only — it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, G→maxScrollExtent AND re-arm _atBottom follow-tail, gg→0). Lists (tickets/git) trail in a follow-up commit. Navigation only — no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability — land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, gg→offset 0, G→_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart — coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag — VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard — (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-12 20:03:58', NULL, '7364d54f0bb8aa1084227b4b474bc391', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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?', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-12 20:04:07', NULL, 'f4926cd7346fa26a4301f248e8977c0c', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w …, g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift — and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST — it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path — land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet — T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) — coordinate the shared-prefix matcher tests.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-12 20:05:16', NULL, 'c19ee14d0a7d089f7f3498e4773af343', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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.)', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:39:41', NULL, 'db54855fb1bd9291941da7dc13f40242', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:41:23', NULL, '8e7a4dafca6a2689ff7ed36862209a85', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml — intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists — check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary — verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later — the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only — no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only — it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, G→maxScrollExtent AND re-arm _atBottom follow-tail, gg→0). Lists (tickets/git) trail in a follow-up commit. Navigation only — no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability — land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, gg→offset 0, G→_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart — coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag — VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard — (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 11:42:26', NULL, 'a1258e7d47ac99671a66d47eed661939', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w …, g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift — and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST — it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path — land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet — T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) — coordinate the shared-prefix matcher tests.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-13 11:45:19', NULL, '5518d537ba94da58e79fb00562bab03a', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` → command:panel.focus.left; `ctrl+w l` → command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` → command:dock.toggle — document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` → focus.nextPanel; `ctrl+w shift+w` → focus.previousPanel
- `ctrl+w o` → command:panel.focusMode (vim "only" — exact semantic match)
- `ctrl+w q` and `ctrl+w c` → command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset — bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands — but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent → Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings — it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`→panel.focus.left, `ctrl+w l`→panel.focus.right, `ctrl+w j`→dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`→focus.nextPanel, `ctrl+w shift+w`→focus.previousPanel, `ctrl+w o`→panel.focusMode, `ctrl+w q`/`ctrl+w c`→editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains — editor_presets_test.dart `ctrl+w → editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey — single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords — decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own — non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface — coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live — a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately — confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists — intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t — ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-13 11:47:21', NULL, '0a109484fa4b7e20860d503cebb1c612', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap — only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle — so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`→workspace.tab.next and `g shift+t`→workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntent→commands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it — but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`→workspace.tab.next, `g shift+t`→workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` — same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now — needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-13 11:47:26', NULL, 'b987c27388c5af5841dbf454e365de32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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.', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:47:31', NULL, 'e8a308082017a6bdbb79aa0cff8c9c5d', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml — intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists — check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary — verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later — the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only — no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only — it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, G→maxScrollExtent AND re-arm _atBottom follow-tail, gg→0). Lists (tickets/git) trail in a follow-up commit. Navigation only — no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability — land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, gg→offset 0, G→_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart — coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag — VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard — (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 12:45:43', NULL, '5d7f59ef58611f7fe6c8a4cb3e755154', 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 ('06FC2XY1T85A65YY9SG25VVEY4', 'bug', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Worktree-safe git hooks + WorktreeCreate bootstrap (pql hooks break git worktree add)', 'PROBLEM (diagnosed 2026-06-13): `pql init` installs .githooks/post-checkout that unconditionally sources an UNTRACKED .pql/hooks/post-checkout (which does `pql plan rebuild` on branch checkout). A fresh `git worktree add` checks out only TRACKED files, so that hook is absent in every new worktree → the `.` source fails and the post-checkout hook exits non-zero, which `git worktree add` propagates as a hard failure. This breaks Claude Code agent isolation:''worktree'' (and any clide worktree flow) in EVERY clide repo that ran pql init — and, because the bug is in pql''s init template, in every pql-using repo, not just clide.
REPRO: `git worktree add <path> -b <branch> HEAD` fails with exit 1 from .githooks/post-checkout (the source of the absent untracked hook). Confirmed both sequential and concurrent.
REPO-LOCAL FIX (DONE — commits 008779c then 054eaf6 on main): .githooks/post-checkout now guards with `if [ -f "$hook" ]; then . "$hook"; fi` and forces `exit 0` (post-checkout is best-effort and must never abort a checkout/worktree). NB: the first attempt `[ -f x ] && . x` was itself buggy — returns 1 when the file is absent (the script''s last statement), still aborting.
PERMANENT FIX (this ticket — clide repo AND clide-the-product, since clide ships to other devs and owns workspace onboarding per T-354):
1. FIX THE TEMPLATE AT SOURCE: the worktree-safe hook (if-guard + exit 0) must be what gets INSTALLED, not a one-off patch. As clide internalizes pql (T-354/T-355), clide should own/patch the post-checkout hook install so every clide-managed workspace is worktree-safe. ALSO report upstream to pql — its init hook template has this latent bug for all pql users.
2. WORKTREE BOOTSTRAP via a WorktreeCreate hook: ship a .claude/settings.json `WorktreeCreate` hook (verified real Claude Code hook — fires on harness worktree creation for --worktree / EnterWorktree / agent isolation; receives {hook_event_name,cwd,name} on stdin; MUST print the new worktree''s absolute path on stdout) that copies the local .pql/hooks/ into the new worktree and runs `pql plan rebuild` so pql state is correct in agent worktrees (the guarded git hook only stops the ABORT; it doesn''t make the rebuild happen in the worktree). Pair with `WorktreeRemove` for teardown. Known caveats / open CC issues: #36205 (EnterWorktree ignores these hooks), #39281 (--worktree --tmux skips them) — so the git-hook safety net in (1) is still needed.
3. baseRef: settings schema `worktree.baseRef` defaults to ''fresh'' (branches worktrees from origin/<default-branch>), so UNPUSHED local commits are absent in agent worktrees — which silently breaks foundation-first agent fan-outs (observed: 2 of 3 agents branched from origin/main without the local foundation). For clide''s commit-locally / batch-push dev flow, set `worktree.baseRef: "head"` in clide''s .claude/settings.json (or document the tradeoff).
FILES: .githooks/post-checkout (repo fix done); .claude/settings.json (add WorktreeCreate/WorktreeRemove hooks + worktree.baseRef:head); the pql-init / onboarding path clide will own (T-354/T-355); docs/CONTRIBUTING. Upstream: file a pql issue/PR for the init hook template.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '8c0ffbe33ea5f675ac6bd1c1183e9afa', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap — only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle — so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`→workspace.tab.next and `g shift+t`→workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntent→commands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it — but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`→workspace.tab.next, `g shift+t`→workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` — same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now — needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)
--- PROGRESS (2026-06-13) ---
PART 1 DONE (commit on main): workspace.tab.next / workspace.tab.previous cycle commands with wraparound + ctrl+pagedown/ctrl+pageup defaultBindings across every preset. Tests in test/builtin/default_layout/widget_test.dart.
PART 2 (gt/gT) STILL OPEN — and harder than the coordination note assumed. T-404 landed a global multi-chord matcher (root_shell), BUT it deliberately only STARTS a sequence on a MODIFIED chord (ctrl+w). Bare-key prefixes (g) are left editor/pane-local on purpose — otherwise the global matcher would steal `g` before the editor sees it, breaking gg/dd. So gt/gT (bare g) CANNOT just ride the global matcher. Options for part 2: (a) the editor/pane matchers grow gt/gT and dispatch command:workspace.tab.* (but then gt only works when the editor/a pane is focused, not globally); (b) a focus-agnostic bare-g disambiguation (g g = local docStart vs g t = global tab) — needs the global matcher to tentatively grab bare g AND coordinate with the editor''s matcher, which is the exact conflict T-404 avoided. Decide before building. The ctrl+pagedown/up commands already give every preset tab-cycling; gt/gT is a vim-affordance nicety on top.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-13 16:38:16', NULL, '863a80e6efed7d96db4f5e960ab4e200', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` → command:panel.focus.left; `ctrl+w l` → command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` → command:dock.toggle — document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` → focus.nextPanel; `ctrl+w shift+w` → focus.previousPanel
- `ctrl+w o` → command:panel.focusMode (vim "only" — exact semantic match)
- `ctrl+w q` and `ctrl+w c` → command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset — bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands — but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent → Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings — it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`→panel.focus.left, `ctrl+w l`→panel.focus.right, `ctrl+w j`→dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`→focus.nextPanel, `ctrl+w shift+w`→focus.previousPanel, `ctrl+w o`→panel.focusMode, `ctrl+w q`/`ctrl+w c`→editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains — editor_presets_test.dart `ctrl+w → editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey — single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords — decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own — non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface — coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live — a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately — confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists — intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t — ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-13 19:55:21', NULL, '210abd8257ebd45e153f8fa8329620fa', 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 ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', NULL, 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', NULL, 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:29:23', NULL, '98af6c651543b8584649fbc4461ddb37', 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 ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', NULL, 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows — events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* — see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` — subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` → `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` → kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root → distinct socket per root (D-70).
**Two candidate mechanisms — fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* — the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* — separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root — e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed — documents the isolation invariant this breaks), T-367 (closed — shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).', 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:29:27', NULL, '1dc670c4dd0837770970ff5da8c5e464', 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 ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', NULL, 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows — events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* — see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` — subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` → `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` → kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root → distinct socket per root (D-70).
**Two candidate mechanisms — fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* — the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* — separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root — e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed — documents the isolation invariant this breaks), T-367 (closed — shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).
---
**Repro details confirmed (user, 2026-06-14):**
- The two windows were on *different repos* (distinct workspace roots → distinct hashed sockets per D-70; rules out same-socket collision).
- The second window was opened from the **File menu at the top**, not from an integrated terminal.
**Refined root-cause analysis (this changes the leading hypothesis).**
The File menu has two distinct paths (`lib/builtin/menubar/src/file_actions.dart`):
- `openFolder()`/`openPath()` (l.23-63) → `services.project.open()` = *in-place* switch, same process (the T-269/T-367 class). Produces ONE window, so not this report.
- `newWindow()` (l.30-32) → `Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached)` = a genuinely **separate detached process**. This matches the "parallel windows" symptom.
Two facts narrow it:
1. `CLIDE_SOCK`/`CLIDE_WORKSPACE` are NOT set in clide''s own process environment — they are a delta overlaid only on spawned Claude/PTY *child* processes (`lib/builtin/claude/src/agent_bootstrap.dart:57-71`, "Process.start keeps the parent environment by default, so this returns only the keys to add/override"). So a clean dock-launched window has no CLIDE_SOCK to leak.
2. `newWindow()` passes **no `environment:` override**, so the detached child inherits the parent clide process''s full environment verbatim.
**Leading hypothesis now:** environment inheritance through `newWindow()` when clide is self-hosted. If window 1 was itself launched from a clide-hosted terminal or as a clide agent, window 1''s process env already carries *that host''s* `CLIDE_SOCK`/`CLIDE_WORKSPACE`. `newWindow()` then spawns window 2 inheriting those vars — so any code in window 2 that resolves its IPC endpoint (or shells out to the `clide` CLI, which keys off `CLIDE_SOCK`) can bind to the wrong workspace''s server and surface its branch. This is consistent with: different repos, opened from the File menu, intermittent.
**Caveat / not yet pinned:** the in-app status widget reportedly resolves IPC via the computed `workspaceSocketPath(root)` (`lib/main.dart:357`), NOT via `CLIDE_SOCK` — so if that holds, inherited CLIDE_SOCK alone shouldn''t mislead the *in-process* status bar. The exact cross-process channel therefore still needs live confirmation. Do NOT assume; instrument.
**First diagnostic step for the fixer:**
1. Reproduce: open window 1, then File → New Window, then open a *different* repo in window 2.
2. Log, in each window at branch-fetch time: the resolved socket path the status client connected to, `Platform.environment[''CLIDE_SOCK'']`, `Platform.environment[''CLIDE_WORKSPACE'']`, and `kernel.project.root`. The window showing the wrong branch will reveal whether it (a) connected to the other window''s socket, (b) read a stale/ambient env var, or (c) received a cross-process event it shouldn''t have.
**Hardening regardless of outcome:** `newWindow()` should spawn the child with an explicit, scrubbed environment — strip `CLIDE_SOCK`/`CLIDE_WORKSPACE` (and not rely on inheriting them) so a fresh window always computes its own per-root socket from its own workspace. A new window must never inherit another workspace''s IPC identity.', 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:41:41', NULL, 'a3a009799c3acada9474e9657a2a89c2', 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 ('06FCDKX4CVHWVGDAJC6X09602M', 'epic', NULL, 'Unify workspace lifecycle on a single fenced open primitive (Q-51)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 15:45:57', '2026-06-14 15:45:57', NULL, 'ca426dc83f5cf2d6d98dd92b79583cc4', 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 ('06FCDKX4CVHWVGDAJC6X09602M', 'epic', NULL, 'Unify workspace lifecycle on a single fenced open primitive (Q-51)', 'Tracks the architectural unification behind Q-51: replace the scattered, per-entry-point workspace-open logic with a single fenced primitive.
**The problem.** There is no "open workspace X" primitive — only two half-primitives in different layers:
- `project.open(root)` (`lib/kernel/src/project.dart:143`) — the only repo-targeting path, intrinsically *in-place*: rebuilds services in the same process reusing the shared `daemonBus` (`lib/main.dart:372-376`).
- `newWindow()` (`lib/builtin/menubar/src/file_actions.dart:30-32`) — a blank detached `Process.start` with no repo argument and no env scrubbing.
To open a repo in a new window you spawn a blank window and then run the in-place switch inside it. Every fencing bug to date is a spot where one path forgets what the other remembers.
**Symptoms already filed (same root):** T-421 (status-bar branch bleeds across parallel windows), T-367 (in-place switch leaked the previous service set — closed), T-269 (kept the previous repo''s Claude session — closed).
**Target invariant.** `workspace root ⇒ socket ⇒ bus ⇒ session-id`, one-to-one. Exactly one place derives IPC identity from a root. Every entry point (File menu, project switcher, `clide://` deep link, CLI, recents) routes through `WorkspaceService.open(root, {target: thisWindow | newWindow})`. New-window spawns `Process.start(exe, [''--workspace'', root], environment: <scrubbed>)` — explicit root, no inherited `CLIDE_SOCK`/`CLIDE_WORKSPACE`.
**Open decision (Q-51):** whether in-place switching survives at all, or whether a workspace is always its own window/process. If abolished, the teardown burden that T-367/T-269 patch disappears.
**Acceptance:** Q-51 resolved with a D-record fixing the in-place-vs-window stance; a single workspace-open primitive in place; all entry points routed through it; T-421 no longer reproducible; a regression test that a checkout in one workspace cannot change another''s displayed branch.
See Q-51 (governance/questions/architecture.md), D-70, D-56, D-72.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 15:45:57', '2026-06-14 15:47:08', NULL, '86160ba4a7a094bc54b36f9071c4e561', 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 ('06FCDM61KAA3GV3CVTE8PAZ8N0', 'task', '06FCDKX4CVHWVGDAJC6X09602M', 'Build WorkspaceService.open(root, target) — single fenced workspace-open primitive; route all entry points through it', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, 'b1e5651ed4d4f3f9a59f1baa0f3a72a6', 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 ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', '06FCDKX4CVHWVGDAJC6X09602M', 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows — events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* — see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` — subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` → `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` → kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root → distinct socket per root (D-70).
**Two candidate mechanisms — fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* — the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* — separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root — e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed — documents the isolation invariant this breaks), T-367 (closed — shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).
---
**Repro details confirmed (user, 2026-06-14):**
- The two windows were on *different repos* (distinct workspace roots → distinct hashed sockets per D-70; rules out same-socket collision).
- The second window was opened from the **File menu at the top**, not from an integrated terminal.
**Refined root-cause analysis (this changes the leading hypothesis).**
The File menu has two distinct paths (`lib/builtin/menubar/src/file_actions.dart`):
- `openFolder()`/`openPath()` (l.23-63) → `services.project.open()` = *in-place* switch, same process (the T-269/T-367 class). Produces ONE window, so not this report.
- `newWindow()` (l.30-32) → `Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached)` = a genuinely **separate detached process**. This matches the "parallel windows" symptom.
Two facts narrow it:
1. `CLIDE_SOCK`/`CLIDE_WORKSPACE` are NOT set in clide''s own process environment — they are a delta overlaid only on spawned Claude/PTY *child* processes (`lib/builtin/claude/src/agent_bootstrap.dart:57-71`, "Process.start keeps the parent environment by default, so this returns only the keys to add/override"). So a clean dock-launched window has no CLIDE_SOCK to leak.
2. `newWindow()` passes **no `environment:` override**, so the detached child inherits the parent clide process''s full environment verbatim.
**Leading hypothesis now:** environment inheritance through `newWindow()` when clide is self-hosted. If window 1 was itself launched from a clide-hosted terminal or as a clide agent, window 1''s process env already carries *that host''s* `CLIDE_SOCK`/`CLIDE_WORKSPACE`. `newWindow()` then spawns window 2 inheriting those vars — so any code in window 2 that resolves its IPC endpoint (or shells out to the `clide` CLI, which keys off `CLIDE_SOCK`) can bind to the wrong workspace''s server and surface its branch. This is consistent with: different repos, opened from the File menu, intermittent.
**Caveat / not yet pinned:** the in-app status widget reportedly resolves IPC via the computed `workspaceSocketPath(root)` (`lib/main.dart:357`), NOT via `CLIDE_SOCK` — so if that holds, inherited CLIDE_SOCK alone shouldn''t mislead the *in-process* status bar. The exact cross-process channel therefore still needs live confirmation. Do NOT assume; instrument.
**First diagnostic step for the fixer:**
1. Reproduce: open window 1, then File → New Window, then open a *different* repo in window 2.
2. Log, in each window at branch-fetch time: the resolved socket path the status client connected to, `Platform.environment[''CLIDE_SOCK'']`, `Platform.environment[''CLIDE_WORKSPACE'']`, and `kernel.project.root`. The window showing the wrong branch will reveal whether it (a) connected to the other window''s socket, (b) read a stale/ambient env var, or (c) received a cross-process event it shouldn''t have.
**Hardening regardless of outcome:** `newWindow()` should spawn the child with an explicit, scrubbed environment — strip `CLIDE_SOCK`/`CLIDE_WORKSPACE` (and not rely on inheriting them) so a fresh window always computes its own per-root socket from its own workspace. A new window must never inherit another workspace''s IPC identity.', 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:47:10', NULL, 'b31a1fda9bb8515f031dee671fd43e85', 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 ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'aebb39f1f9e7ce55bfae7807a997def8', 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 ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'epic', NULL, 'Crash-survivable logging & observability (FileLogSink, FFI breadcrumbs, watchdog, dev/prod verbosity)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, '4f8092cb3ef7981f36e0aae2b4e6f698', 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 ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 — the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI — dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:15:41', NULL, '3535b2ad3cc348e9798fb315ec03cf57', 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 ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'epic', NULL, 'Crash-survivable logging & observability (FileLogSink, FFI breadcrumbs, watchdog, dev/prod verbosity)', 'From the Windows test-freeze analysis (2026-06-14): the freeze left NO evidence because every log sink is volatile — stderrSink dies with the console and the in-RAM LogRing dies with the power-cycle. clide already has the logger scaffolding (lib/kernel/src/log.dart: Logger, LogLevel{trace..error}, pluggable LogSink; lib/kernel/src/log_ring.dart; the output dock + Level chip), so this epic does not add a framework — it bolts on a crash-survivable sink, FFI breadcrumbs, a watchdog, and the dev/prod verbosity toggle so the NEXT freeze (Windows or otherwise) leaves on-disk evidence that names the wedged call.
Child work (each filed as a task under this epic):
1. FileLogSink — synchronous-fsync JSON-lines sink to %LOCALAPPDATA%\clide\logs (reuse ipc/paths.dart socket-dir helper); tiered flush (warn/error + any pty/ffi record flush immediately, info/debug batch on a timer); first sink in the chain so a crash cannot lose the tail; size-capped with rotation.
2. FFI breadcrumbs in windows_pty.dart — inject a no-op-by-default log callback; emit BEFORE/AFTER every risky Win32 call with the return value + GetLastError read immediately; the reader/waiter SPAWNED isolates each open their OWN append handle to the log file and flushSync per breadcrumb, so the wedged isolate''s last line survives a frozen main isolate.
3. Watchdog heartbeat + resource sampler — a DEDICATED isolate (NOT a main-isolate Timer, which would freeze with it) appending+fsyncing a heartbeat every ~500ms and sampling live ConPTY child count / process handle count / thread count / memory load every ~2s. A monotonically climbing child count is the leak signature; the last heartbeat bounds the freeze window to ~500ms.
4. Dev/prod verbosity toggle (the requested switch) — resolve Logger.minLevel once at boot: CLIDE_LOG dart-define -> CLIDE_LOG env var -> settings.json log.level -> default warn (release) / info (debug). Level also gates FileLogSink flush-eagerness (debug = lose nothing in a repro). Live changes via a /loglevel command + `clide log level <level>` CLI (D-6 parity); the output-dock Level chip is the in-UI affordance.
5. Wire into the testmode harness + ci/test.sh — attach FileLogSink in lib/test_app.dart with per-test start/end breadcrumbs; export CLIDE_LOG=debug and a log dir OUTSIDE the build tree in ci/test.sh; upload that dir as a CI artifact in an always() step so a CI freeze leaves evidence.
Verification kit for the leak this telemetry is meant to catch: tools/windows-verify/.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:15:42', NULL, '9b7c6b83ebe8d600f59ea5eee6431bee', 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 ('06FCEP60AS6AF654SWA189A5ZR', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink: crash-survivable fsync JSON-lines disk sink', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '2313f7e48e20d169b2acf709a5bd5642', 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 ('06FCEP642C8ZZ1T20RXQQ3143M', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs around every Win32 call in windows_pty.dart (per-isolate append handles)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:43', '2026-06-14 18:15:43', NULL, '1e68b3f5086ec0825e9d58d6630735e2', 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 ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource-sampler isolate', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:44', '2026-06-14 18:15:44', NULL, '29877b59225c360ce771d026cdefecba', 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 ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Dev/prod log verbosity toggle: CLIDE_LOG, settings.json, /loglevel, clide log level CLI', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:45', '2026-06-14 18:15:45', NULL, '545a96116e3b1f2807a8baf888828760', 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 ('06FCEP6EVN9S35T02MHA2AS7YW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire crash-survivable logging into testmode harness + ci/test.sh CI artifact', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 18:15:46', '2026-06-14 18:15:46', NULL, 'faa3d5305686482bd7390a4d30a52fed', 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 ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 — the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI — dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch — cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:55:21', NULL, 'ef69bc22ce4d3a550b305cca44a43824', 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 ('06FCGJ30V24BJB001GZCR5QKTC', 'task', NULL, 'Extract pure PTY logic from FFI methods into Linux-testable helpers; shrink coverage-ignore to raw syscalls', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 22:37:27', '2026-06-14 22:37:27', NULL, 'b5f6cb297222325f80e292f780926a24', 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 ('06FCGJ30V24BJB001GZCR5QKTC', 'task', NULL, 'Extract pure PTY logic from FFI methods into Linux-testable helpers; shrink coverage-ignore to raw syscalls', 'From the PTY testability audit (2026-06-14), prompted by the question "is any of the Windows FFI code testable on a pure I/O basis without Windows, and vice-versa?" Answer: yes on both backends, but the pure fragments are entangled with the syscall layer and need extraction before they can be unit-tested. Each claim below was adversarially verified (default-reject; rejected the rest of the proposed fragments because their output IS a syscall return, e.g. GetLastError, WriteFile byte count).
Context: lib/src/pty/windows_pty.dart wraps its FFI span in `// coverage:ignore-start/end` (Linux runner has no kernel32; the syscall sites are genuinely uncoverable off-Windows, and the bindings resolve through one DynamicLibrary.open so a method touching a binding can''t be entered on Linux). That exclusion is correct for the gate, but it hides a few pure transforms at file granularity. native_pty.dart (POSIX) has the mirror problem: it runs on Linux at ~78.9% but its pure marshalling is only covered incidentally by real spawns, never unit-tested.
## Windows (windows_pty.dart) — extract + unit-test on Linux
Confirmed pure (verifier-approved), currently untested:
- `_Coord` struct packing (69-74) — two clamped int16s into COORD; allocate via calloc, set x/y, read back.
- `_StartupInfoExW` field assembly in start() (358-361) — cb / dwFlags=STARTF_USESTDHANDLES / lpAttributeList; deterministic field writes over calloc-zeroed memory.
- `write()` empty/length guard (497-498) — returns 0 when `_dead` or `bytes.isEmpty`, before any WriteFile.
Plan: pull the COORD/STARTUPINFOEXW packing into free functions (e.g. `packCoord(cols, rows)`, `buildStartupInfoEx(attrList)`) that take/return plain values and don''t reference the kernel32 bindings; assert field layout in a Linux unit test. Keep the empty-guard logic in a tiny pure predicate.
## POSIX (native_pty.dart) — extract + unit-test directly (closes part of the 21% gap, adds gate margin)
Confirmed pure (verifier-approved), currently only covered incidentally by integration spawns:
- argv marshalling (222-228) — String list -> native UTF8 pointer array + null terminator.
- envp marshalling (230-235) — Map<String,String> -> native ''KEY=VALUE'' UTF8 array.
- write() buffer copy (406-408) — bytes[i] -> buf[i].
- resize() Winsize init + clamp (432-435) — cols/rows -> ws.wsCol/wsRow (clamp already tested in pty_size_test).
Plan: extract marshalling into free helpers returning the pointer structures (inject the allocator so a test can read them back and free them); unit-test the round-trip and null-termination off any real spawn.
## NOT in scope (genuinely host-bound — leave excluded/uncovered)
All the raw syscalls and anything whose output is a syscall return or that has no injection seam: CreatePipe / CreatePseudoConsole / CreateProcessW / ReadFile / WaitForSingleObject / WriteFile / ResizePseudoConsole / TerminateProcess / CloseHandle / GetLastError; the attribute-list APIs; the read/wait isolate bodies; and on POSIX the openpt/grantpt/unlockpt/ptsname + posix_spawn failure paths, EINTR/EBADF/EPIPE handling, and reader-isolate EOF reaping (~32 lines that need real OS error/timing state).
## Acceptance
- New Linux unit tests for the fragments above (both backends).
- windows_pty.dart `coverage:ignore` span narrowed to only the syscall sites (struct-packing helpers move out and are measured).
- Coverage floor holds (or ratchets up from the added native_pty coverage).
## Related / separate finding (file or fold as decided)
windows.yml runs the real ConPTY suite (start/write/resize/kill/errors) on windows-latest but collects NO coverage (no --coverage flag). So the FFI spawn path has functional validation on Windows + the VM soak (tools/windows-verify/) but no line-coverage metric anywhere. Decide whether to (a) accept functional-only validation explicitly, or (b) collect coverage on the Windows runner and merge it so the FFI path is measured. Cross-platform lcov merge is non-trivial (the gate reads one file) — may warrant a Q-record.
Audit detail: full per-fragment findings + adversarial verdicts in the workflow result for run wf_a3cacb2c-2c7.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 22:37:27', '2026-06-14 22:38:01', NULL, '43890d3049d818cea0acd681a191bc94', 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 ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 — the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI — dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch — cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).
## Soak results on GitHub windows-latest (Server 2022) — orphan-accumulation NOT reproduced (2026-06-14)
Ran both halves of the windows-verify soak on GitHub-hosted Windows (no VM needed — windows-latest runs the ConPTY suite green, so the soak just wraps it):
1. Clean-path soak (soak-conpty.ps1, 25 iters): orphans stayed at 0, dart handles flat ~152, threads flat at 7. Orderly close() reaps everything. NOT REPRODUCED.
2. Abrupt-death probe (soak-conpty-kill.ps1 + conpty_orphan_probe.dart, 15 iters x 2 PTYs): start real WindowsPty sessions on long-lived children, block WITHOUT close(), then taskkill /F the parent dart.exe (no /T). Every cycle reaped to baseline — survivors=0, cum=0. When the parent dies the OS breaks the pipes and conhost exits on its own. NOT REPRODUCED.
**Implication:** the conhost-orphan-accumulation mechanism this ticket is premised on does NOT hold on Server 2022, under clean OR abrupt teardown. The Job Object fix may still be worthwhile as defense-in-depth, but its justification (a reproduced leak) is not confirmed.
**Caveats / what''s still untested:**
- OS mismatch: the real crashes were on desktop Win10/11; this is headless Server 2022. terminal#4050 was a desktop report. A desktop-specific behavior may be unreproducible on CI.
- Both probes let the process DIE, so within-process accumulation (culprit #2: reader isolates blocked forever in ReadFile, threads/handles climbing within one long-lived process) is reclaimed at exit and never measured. A long-lived-process probe (one dart.exe spawning + abandoning PTYs, watching its OWN handle/thread count climb) would test that — the more likely freeze mode for a long-running app. Not yet built.
Diagnostics live in tools/windows-verify/ and run via .github/workflows/windows-soak.yml (workflow_dispatch). The same kill-probe will validate the fix if/when it lands (survivors should stay 0 — though they already do, which is the problem).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-15 07:11:41', NULL, '7103394bfdac9685026b652023a7569e', 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 ('06FCM9ER04JVFW8CN3JW1AWYA8', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink + logDirectory + boot-time verbosity resolver (CLIDE_LOG → env → setting → release/debug default)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 07:18:58', '2026-06-15 07:18:58', NULL, '7f08c492c5eb3f6642719f0b6990f7cd', 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 ('06FCM9F446MZFXVHH65Q6CKTPM', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Live verbosity toggle: clide log level CLI + /loglevel command + sync output-dock Level chip to kernel Logger + persist app.log.level', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:01', '2026-06-15 07:19:01', NULL, '7801e4323815cf95bdc78fa005611cf0', 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 ('06FCM9FHC8VX50759X35VNER1R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs in windows_pty.dart (+native_pty): injectable log callback, before/after each risky syscall with return + GetLastError; reader/waiter isolates flushSync their own append handle', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, 'd796d50acb2751cc8732a20d3805a77f', 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 ('06FCM9FYDEXCM15FXTER032K84', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource sampler in a dedicated isolate (heartbeat ~500ms; sample ConPTY child / handle / thread / memory ~2s)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, 'a51aac4a65ec7589c8181655aeeb73e9', 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 ('06FCM9GAQ2G0KCVMZS67SK3324', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire FileLogSink into test harness + ci/test.sh (CLIDE_LOG=debug, log dir outside build tree, upload as CI artifact in always() step)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '1c35b3eea276c8d7d442e8b09f2c7ad8', 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 ('06FCM9ER04JVFW8CN3JW1AWYA8', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink + logDirectory + boot-time verbosity resolver (CLIDE_LOG → env → setting → release/debug default)', NULL, 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 07:18:58', '2026-06-15 07:19:13', NULL, 'f0ac1b130b28ff2e01db483a5fa47ab7', 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 ('06FCM9ER04JVFW8CN3JW1AWYA8', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink + logDirectory + boot-time verbosity resolver (CLIDE_LOG → env → setting → release/debug default)', NULL, 'done', 'high', NULL, NULL, NULL, '2026-06-15 07:18:58', '2026-06-15 07:29:45', NULL, '2a0f0ae1b6fad09cdbf46e9469be0ec9', 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 ('06FCM9FHC8VX50759X35VNER1R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs in windows_pty.dart (+native_pty): injectable log callback, before/after each risky syscall with return + GetLastError; reader/waiter isolates flushSync their own append handle', NULL, 'done', 'high', NULL, NULL, NULL, '2026-06-15 07:19:04', '2026-06-15 07:56:56', NULL, '8c16b539c378e46174b52197a1a26bf0', 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 ('06FCM9FYDEXCM15FXTER032K84', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource sampler in a dedicated isolate (heartbeat ~500ms; sample ConPTY child / handle / thread / memory ~2s)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:08', '2026-06-15 08:15:52', NULL, 'c72bedb8cc95ae2232767537f7832087', 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 ('06FCM9GAQ2G0KCVMZS67SK3324', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire FileLogSink into test harness + ci/test.sh (CLIDE_LOG=debug, log dir outside build tree, upload as CI artifact in always() step)', NULL, 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:11', '2026-06-15 08:49:49', NULL, '21a64df388e4f8de2ed217fe93b29207', 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 ('06FCM9GAQ2G0KCVMZS67SK3324', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire FileLogSink into test harness + ci/test.sh (CLIDE_LOG=debug, log dir outside build tree, upload as CI artifact in always() step)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:11', '2026-06-15 08:58:55', NULL, 'fccce682fade959d7f1e6063c7b760e4', 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 ('06FCM9F446MZFXVHH65Q6CKTPM', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Live verbosity toggle: clide log level CLI + /loglevel command + sync output-dock Level chip to kernel Logger + persist app.log.level', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:01', '2026-06-15 10:33:01', NULL, 'b06c2bea9e87240bf8c61d116383588a', 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 ('06FCEP60AS6AF654SWA189A5ZR', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink: crash-survivable fsync JSON-lines disk sink', 'Duplicate of the T-425 breakdown — I re-filed this as T-432 (FileLogSink) and implemented + closed that. Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:42', '2026-06-15 10:34:06', NULL, '1875bab57b32f5894fe7ed170079b3c6', 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 ('06FCEP642C8ZZ1T20RXQQ3143M', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs around every Win32 call in windows_pty.dart (per-isolate append handles)', 'Duplicate — re-filed + implemented + closed as T-434 (FFI breadcrumbs). Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:43', '2026-06-15 10:34:12', NULL, '03387ed3cdb73f0236bd1f059d51c8b6', 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 ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource-sampler isolate', 'Duplicate — re-filed + implemented + closed as T-435 (watchdog). Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:44', '2026-06-15 10:34:16', NULL, 'c7bb0abc8e7f23d3f7d6ae96048154d6', 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 ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Dev/prod log verbosity toggle: CLIDE_LOG, settings.json, /loglevel, clide log level CLI', 'Duplicate — re-filed + implemented + closed as T-433 (verbosity toggle: dock chip + clide log level CLI). Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:45', '2026-06-15 10:34:20', NULL, '551e05509fc447a2c09c659933cd0a9b', 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 ('06FCEP6EVN9S35T02MHA2AS7YW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire crash-survivable logging into testmode harness + ci/test.sh CI artifact', 'Duplicate — re-filed + implemented + closed as T-436 (CI crash-evidence artifacts). Cancelling as duplicate; work is done.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 18:15:46', '2026-06-15 10:34:25', NULL, '59c1410dae4995e6c7c479775548d0ca', 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 ('06FCEP642C8ZZ1T20RXQQ3143M', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs around every Win32 call in windows_pty.dart (per-isolate append handles)', 'Duplicate — re-filed + implemented + closed as T-434 (FFI breadcrumbs). Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:43', '2026-06-15 10:34:28', NULL, '06f807d2c4b08f10c32489d90b7d8886', 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 ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource-sampler isolate', 'Duplicate — re-filed + implemented + closed as T-435 (watchdog). Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:44', '2026-06-15 10:34:28', NULL, '5a716101aced1d744f80079918c09b83', 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 ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Dev/prod log verbosity toggle: CLIDE_LOG, settings.json, /loglevel, clide log level CLI', 'Duplicate — re-filed + implemented + closed as T-433 (verbosity toggle: dock chip + clide log level CLI). Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:45', '2026-06-15 10:34:28', NULL, 'cb5429b84bf6ea732c4cc9eea0e0f79a', 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 ('06FCEP60AS6AF654SWA189A5ZR', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink: crash-survivable fsync JSON-lines disk sink', 'Duplicate of the T-425 breakdown — I re-filed this as T-432 (FileLogSink) and implemented + closed that. Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:42', '2026-06-15 10:34:28', NULL, 'd6929a4bf9421e939e1b195b7f4047b9', 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 ('06FCEP6EVN9S35T02MHA2AS7YW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire crash-survivable logging into testmode harness + ci/test.sh CI artifact', 'Duplicate — re-filed + implemented + closed as T-436 (CI crash-evidence artifacts). Cancelling as duplicate; work is done.', 'cancelled', 'medium', NULL, NULL, NULL, '2026-06-14 18:15:46', '2026-06-15 10:34:28', NULL, 'ff037583f22cbef60a5b23c201c2b7ae', 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 ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'epic', NULL, 'Crash-survivable logging & observability (FileLogSink, FFI breadcrumbs, watchdog, dev/prod verbosity)', 'From the Windows test-freeze analysis (2026-06-14): the freeze left NO evidence because every log sink is volatile — stderrSink dies with the console and the in-RAM LogRing dies with the power-cycle. clide already has the logger scaffolding (lib/kernel/src/log.dart: Logger, LogLevel{trace..error}, pluggable LogSink; lib/kernel/src/log_ring.dart; the output dock + Level chip), so this epic does not add a framework — it bolts on a crash-survivable sink, FFI breadcrumbs, a watchdog, and the dev/prod verbosity toggle so the NEXT freeze (Windows or otherwise) leaves on-disk evidence that names the wedged call.
Child work (each filed as a task under this epic):
1. FileLogSink — synchronous-fsync JSON-lines sink to %LOCALAPPDATA%\clide\logs (reuse ipc/paths.dart socket-dir helper); tiered flush (warn/error + any pty/ffi record flush immediately, info/debug batch on a timer); first sink in the chain so a crash cannot lose the tail; size-capped with rotation.
2. FFI breadcrumbs in windows_pty.dart — inject a no-op-by-default log callback; emit BEFORE/AFTER every risky Win32 call with the return value + GetLastError read immediately; the reader/waiter SPAWNED isolates each open their OWN append handle to the log file and flushSync per breadcrumb, so the wedged isolate''s last line survives a frozen main isolate.
3. Watchdog heartbeat + resource sampler — a DEDICATED isolate (NOT a main-isolate Timer, which would freeze with it) appending+fsyncing a heartbeat every ~500ms and sampling live ConPTY child count / process handle count / thread count / memory load every ~2s. A monotonically climbing child count is the leak signature; the last heartbeat bounds the freeze window to ~500ms.
4. Dev/prod verbosity toggle (the requested switch) — resolve Logger.minLevel once at boot: CLIDE_LOG dart-define -> CLIDE_LOG env var -> settings.json log.level -> default warn (release) / info (debug). Level also gates FileLogSink flush-eagerness (debug = lose nothing in a repro). Live changes via a /loglevel command + `clide log level <level>` CLI (D-6 parity); the output-dock Level chip is the in-UI affordance.
5. Wire into the testmode harness + ci/test.sh — attach FileLogSink in lib/test_app.dart with per-test start/end breadcrumbs; export CLIDE_LOG=debug and a log dir OUTSIDE the build tree in ci/test.sh; upload that dir as a CI artifact in an always() step so a CI freeze leaves evidence.
Verification kit for the leak this telemetry is meant to catch: tools/windows-verify/.', 'done', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-15 10:34:31', NULL, 'ecac06a5aebea9665c50a2cacaceb825', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', NULL, 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '6207abaa954ac63b0b7d76540e2292b8', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 11:12:40', NULL, '4a25498623ea06f378d4f55ef1135c0f', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe — part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at ≤ 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed — esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 11:30:26', NULL, 'e07a8de1900862cd38a0193aca4846e4', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe — part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at ≤ 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed — esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', 'in_progress', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 11:38:50', NULL, '2749bd938060221306d1241ca188568c', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe — part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at ≤ 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed — esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', 'in_progress', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 11:40:04', NULL, 'f5f3bfabc43552ffd82b739e14ca7d40', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe — part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at ≤ 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed — esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).
---
**RESOLUTION (2026-06-15) — root cause confirmed, fixed.**
**Actual cause (confirmed against real stderr, NOT the original collision-vs-registry guess).** Captured from clide''s own crash log (`~/.local/state/clide/logs/clide.log`):
`session primary exited (code 1); stderr tail: Error: Session ID <id> is already in use.`
But the "in use" is a **lifecycle race**, not a persistent registry/transcript collision. Isolated probes against claude 2.1.177 proved:
- `--session-id` reuse SUCCEEDS once the prior holder is dead — even immediately after SIGTERM (the `~/.claude/sessions/<pid>.json` entry is cleaned on a SIGTERM exit), and even with another *live* duplicate in `-p` mode.
- A transcript-absent id never collides.
The only way real clide hit "already in use": the **old process was still alive** at respawn. `ClaudeSessionOrchestrator.close()` called `conversation.dispose()` (unawaited) and `ClaudeStreamJsonProcess.kill()` sent SIGTERM **without awaiting `exitCode`** — so `/clear` deleted the transcript and respawned on the same deterministic `--session-id` while the old `claude` was still alive (and re-flushing its transcript). 2.1.177 then rejects the id → exit 1.
**Fix shipped (chose await-death over the ticket''s tentative "mint a fresh id").** Awaiting the old process''s real death is the targeted root-cause fix and *preserves T-268''s deterministic-id continuity* (fresh-id would have required persisting a per-repo current-id and changing the continuity model). Changes:
- `stream_json_session.dart`: `kill()` now awaits `exitCode` (SIGTERM → 2s → SIGKILL → await); `dispose()` is idempotent (cached future) so the conversation''s unawaited dispose and the orchestrator''s awaited one share one teardown; new `SessionEnd.reason` getter (last non-empty stderr line, capped).
- `session_orchestrator.dart`: `close()` awaits `session.dispose()` → returns only once the process is truly dead, so the transcript clear + respawn happen after death.
- `claude_pane.dart`: `_onSessionEnd` surfaces `end.reason` in the status line — no more opaque "code 1" (fix #3).
- `session_naming.dart`: corrected the stale `clearSessionTranscript` doc (the real sidecar is the shared `memory/` dir, not `<id>/`; left untouched) + the await-death precondition.
- Tests: `close()` blocks until process exit (gated fake); `SessionEnd.reason` extraction/cap/empty.
**Folded-in CLI 2.1.177 re-probe — done, no code change needed.**
- `~/.claude/sessions/<pid>.json` registry characterized: PID-keyed, carries `{sessionId, cwd, version, kind:"interactive", entrypoint:"sdk-cli", status}`; written at start, **removed on a SIGTERM/clean exit**. So once clide awaits death, the id is free.
- Init cache (`init-<version>.json`) auto-refreshes at runtime per version — nothing to hand-edit.
- Probed 2.1.177 advertised `slash_commands` (31): no drift that breaks the routing table — none of clide''s `kTuiOnlyCommands` became advertised, and new entries (skills/builtins) correctly fall through to `forward`.
**Verification status.** Unit-tested (the teardown ordering + reason surfacing) and validated against the live CLI via probes. `make test` green. NOT yet exercised in the running GUI (would need `make run` + an interactive `/clear`) — recommend a quick live confirm before closing.', 'in_progress', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 13:30:11', NULL, '5f75ed72b507deff17701061ec3091ae', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe — part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at ≤ 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed — esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).
---
**RESOLUTION (2026-06-15) — root cause confirmed, fixed.**
**Actual cause (confirmed against real stderr, NOT the original collision-vs-registry guess).** Captured from clide''s own crash log (`~/.local/state/clide/logs/clide.log`):
`session primary exited (code 1); stderr tail: Error: Session ID <id> is already in use.`
But the "in use" is a **lifecycle race**, not a persistent registry/transcript collision. Isolated probes against claude 2.1.177 proved:
- `--session-id` reuse SUCCEEDS once the prior holder is dead — even immediately after SIGTERM (the `~/.claude/sessions/<pid>.json` entry is cleaned on a SIGTERM exit), and even with another *live* duplicate in `-p` mode.
- A transcript-absent id never collides.
The only way real clide hit "already in use": the **old process was still alive** at respawn. `ClaudeSessionOrchestrator.close()` called `conversation.dispose()` (unawaited) and `ClaudeStreamJsonProcess.kill()` sent SIGTERM **without awaiting `exitCode`** — so `/clear` deleted the transcript and respawned on the same deterministic `--session-id` while the old `claude` was still alive (and re-flushing its transcript). 2.1.177 then rejects the id → exit 1.
**Fix shipped (chose await-death over the ticket''s tentative "mint a fresh id").** Awaiting the old process''s real death is the targeted root-cause fix and *preserves T-268''s deterministic-id continuity* (fresh-id would have required persisting a per-repo current-id and changing the continuity model). Changes:
- `stream_json_session.dart`: `kill()` now awaits `exitCode` (SIGTERM → 2s → SIGKILL → await); `dispose()` is idempotent (cached future) so the conversation''s unawaited dispose and the orchestrator''s awaited one share one teardown; new `SessionEnd.reason` getter (last non-empty stderr line, capped).
- `session_orchestrator.dart`: `close()` awaits `session.dispose()` → returns only once the process is truly dead, so the transcript clear + respawn happen after death.
- `claude_pane.dart`: `_onSessionEnd` surfaces `end.reason` in the status line — no more opaque "code 1" (fix #3).
- `session_naming.dart`: corrected the stale `clearSessionTranscript` doc (the real sidecar is the shared `memory/` dir, not `<id>/`; left untouched) + the await-death precondition.
- Tests: `close()` blocks until process exit (gated fake); `SessionEnd.reason` extraction/cap/empty.
**Folded-in CLI 2.1.177 re-probe — done, no code change needed.**
- `~/.claude/sessions/<pid>.json` registry characterized: PID-keyed, carries `{sessionId, cwd, version, kind:"interactive", entrypoint:"sdk-cli", status}`; written at start, **removed on a SIGTERM/clean exit**. So once clide awaits death, the id is free.
- Init cache (`init-<version>.json`) auto-refreshes at runtime per version — nothing to hand-edit.
- Probed 2.1.177 advertised `slash_commands` (31): no drift that breaks the routing table — none of clide''s `kTuiOnlyCommands` became advertised, and new entries (skills/builtins) correctly fall through to `forward`.
**Verification status.** Unit-tested (the teardown ordering + reason surfacing) and validated against the live CLI via probes. `make test` green. NOT yet exercised in the running GUI (would need `make run` + an interactive `/clear`) — recommend a quick live confirm before closing.', 'review', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 13:30:21', NULL, 'ae4733f6be976e76598876a08c195dc8', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', NULL, 'backlog', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'e395bef7e2cb2909c044962a9937f5e0', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'backlog', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:22:06', NULL, '1c4954f94958913bec8c5b0ad9792bb8', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.
---
**Closed 2026-06-15 — reconciled.**
The Gitea premise is overtaken by events: CI migrated to **GitHub Actions** (`8e0f33b` Linux, `45aa2d9` Windows/release) — the staged Gitea workflow this bug was about was never activated and is gone (only `legacy/.gitea/` remains). Resolution of the three acceptance criteria:
1. *Workflow steps map to working targets* — done by removal: no Gitea workflow exists; the GitHub `test.yml` goes through `make` targets and withholds the web-WASM e2e job with a Q-50 pointer.
2. *`tools/ui` scripts repointed off the removed `app/`* — done 2026-06-12 (`41af83f`).
3. *D-32 matches reality* — done now: D-32 amended to "GitHub Actions, active".
The one live remnant — the dead `make test-e2e` / `ui-dev` / `ui-smoke` targets — was a pending **decision**, now made: Q-50 resolved → **fence** (D-100). The actual fencing implementation (and re-enabling the e2e targets + CI job) is tracked in **T-438**. Nothing left to do under this ticket.', 'review', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-15 14:25:07', NULL, '175a751e88aa5f577e54e54eb54b78e0', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'make test-e2e / ui-dev / ui-smoke are dead (tools/ui cd into removed app/); staged Gitea CI would fail on activation', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.
---
**Closed 2026-06-15 — reconciled.**
The Gitea premise is overtaken by events: CI migrated to **GitHub Actions** (`8e0f33b` Linux, `45aa2d9` Windows/release) — the staged Gitea workflow this bug was about was never activated and is gone (only `legacy/.gitea/` remains). Resolution of the three acceptance criteria:
1. *Workflow steps map to working targets* — done by removal: no Gitea workflow exists; the GitHub `test.yml` goes through `make` targets and withholds the web-WASM e2e job with a Q-50 pointer.
2. *`tools/ui` scripts repointed off the removed `app/`* — done 2026-06-12 (`41af83f`).
3. *D-32 matches reality* — done now: D-32 amended to "GitHub Actions, active".
The one live remnant — the dead `make test-e2e` / `ui-dev` / `ui-smoke` targets — was a pending **decision**, now made: Q-50 resolved → **fence** (D-100). The actual fencing implementation (and re-enabling the e2e targets + CI job) is tracked in **T-438**. Nothing left to do under this ticket.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-15 14:25:10', NULL, 'dca5c8eb7b213f4a65642bc845fe4ac7', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:32:22', NULL, 'c6c24fce14496683e22a503f5e053518', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:33:11', NULL, '6733711cd4992646502b3d765eb107be', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '4291af0f25de8a8cc5f58e088b7072ce', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:23:47', NULL, '611a14a812f99edc4b615df9d2e8cdc4', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:30:48', NULL, '31d185462126f7a8782eec557356e68a', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'review', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:30:48', NULL, '2820923f9f455f0ab74e76f79661c726', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:31:15', NULL, 'c816196f97e6e80bd828c319f99619c8', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:32:16', NULL, 'cd231fbbec9b6faa4659286c24893d30', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.
---
**Implemented 2026-06-15.** One shared resolver `lib/src/env/shell_env.dart`:
- `primeLoginShellPath()` — probes `$SHELL -l -c` once at startup (sentinel-framed, 4s timeout, graceful fallback to the process PATH on Windows/empty-SHELL/non-zero/timeout/spawn-failure). Primed in `main.dart`''s `!kIsWeb` boot.
- `expandToolPath()` — the canonical user/local-dir merge (moved from toolchain_paths, which re-exports it for its tests).
- `resolvedToolPath()` — the single call every spawn site uses.
Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart` → `operations.dart`), the toolchain probe (`toolchain_paths.dart`), hosted claude (`agent_bootstrap.dart`). Deleted the macOS-only `env.dart` expander and the `cli_install.dart` copy — three expanders → one.
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.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:47:48', NULL, 'e21be607f5fd0f3e0a6ece0f2205f7c3', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.
---
**Implemented 2026-06-15.** One shared resolver `lib/src/env/shell_env.dart`:
- `primeLoginShellPath()` — probes `$SHELL -l -c` once at startup (sentinel-framed, 4s timeout, graceful fallback to the process PATH on Windows/empty-SHELL/non-zero/timeout/spawn-failure). Primed in `main.dart`''s `!kIsWeb` boot.
- `expandToolPath()` — the canonical user/local-dir merge (moved from toolchain_paths, which re-exports it for its tests).
- `resolvedToolPath()` — the single call every spawn site uses.
Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart` → `operations.dart`), the toolchain probe (`toolchain_paths.dart`), hosted claude (`agent_bootstrap.dart`). Deleted the macOS-only `env.dart` expander and the `cli_install.dart` copy — three expanders → one.
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.', 'review', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:47:49', NULL, '7697fe6f58f4c57b24274f694db93f63', 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 ('06FCQZ47MAN835B215GSSMRV8W', 'task', NULL, 'Wire the web-WASM Playwright e2e back into CI (make test-e2e / ui-smoke)', NULL, 'backlog', 'medium', NULL, NULL, 'D-26', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'b6f52c84ffc9a6da071f09294af097fd', 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 ('06FCQZ47MAN835B215GSSMRV8W', 'task', NULL, 'Wire the web-WASM Playwright e2e back into CI (make test-e2e / ui-smoke)', 'Follow-up from T-438 (the `dart:ffi` web fence, D-100): with `flutter build web --wasm` compiling again and a compile gate in CI, restore the *full* web-WASM Playwright e2e — the remaining slice of T-438''s acceptance.
**What''s already done (T-438).** The web build compiles; `.github/workflows/test.yml` has a `web-wasm` job that runs `flutter build web --wasm` (the anti-rot compile gate); `make ui-dev` (build + serve) works.
**What this ticket adds.** The actual browser e2e, which needs runner provisioning the compile gate doesn''t:
- `make test-e2e` / `make ui-smoke` run green locally and in CI (build → serve `localhost:4280` → Playwright smoke → teardown).
- A GitHub Actions job: `setup-node`, `npm install` + `npx playwright install --with-deps` in `tools/ui/`, then `make test-e2e`. Add it to `.github/workflows/test.yml` (replacing the compile-only `web-wasm` job, or as a second job that `needs` it).
- Confirm the Playwright driver (D-26) still matches the current web entrypoint after the fence (degraded web build: no terminal/native-git/highlighting — the smoke should assert what *does* render, e.g. the shell boots and a pane mounts).
**Acceptance.** `make test-e2e` and `make ui-smoke` pass locally; a CI job runs the Playwright smoke against the wasm build on every push/PR; D-26/D-32 reflect the restored e2e job.
**Refs:** T-438 (compile fence + gate), D-100 / Q-50 (fence decision), D-26 (Playwright driver), D-32 (CI; the withheld e2e job).', 'backlog', 'medium', NULL, NULL, 'D-26', '2026-06-15 15:53:05', '2026-06-15 15:53:35', NULL, '8a5cf1cc216e86457c8c9f5ececcb489', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.
Closed 2026-06-15: the dart:ffi fence + flutter build web --wasm compile gate are landed, verified (ca08c2a), and on origin/main. The remaining acceptance slice — the full web-WASM Playwright e2e harness wired into CI (make test-e2e / ui-smoke + the node/playwright runner job) — is split out to T-440, since it needs runner provisioning separate from the fence itself.', 'review', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:53:42', NULL, 'afa700dde7efb5f6cf7cda07fb50643b', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.
Closed 2026-06-15: the dart:ffi fence + flutter build web --wasm compile gate are landed, verified (ca08c2a), and on origin/main. The remaining acceptance slice — the full web-WASM Playwright e2e harness wired into CI (make test-e2e / ui-smoke + the node/playwright runner job) — is split out to T-440, since it needs runner provisioning separate from the fence itself.', 'done', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:53:47', NULL, '6f5194e9f8cfde23da877f4502b5445d', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'bug', NULL, '/clear kills the Claude session (exit 1) — respawn reuses a --session-id the CLI still tracks (2.1.177 regression)', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover ≤ **2.1.175** — the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` — `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` — `_clearSession()`: primary pane → `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` — `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` — `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` — respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis — see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` — a live-session registry. Confirmed contents (2.1.177): `{"pid":…,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",…}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat — not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 — it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) — the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done — built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe — part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at ≤ 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed — esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).
---
**RESOLUTION (2026-06-15) — root cause confirmed, fixed.**
**Actual cause (confirmed against real stderr, NOT the original collision-vs-registry guess).** Captured from clide''s own crash log (`~/.local/state/clide/logs/clide.log`):
`session primary exited (code 1); stderr tail: Error: Session ID <id> is already in use.`
But the "in use" is a **lifecycle race**, not a persistent registry/transcript collision. Isolated probes against claude 2.1.177 proved:
- `--session-id` reuse SUCCEEDS once the prior holder is dead — even immediately after SIGTERM (the `~/.claude/sessions/<pid>.json` entry is cleaned on a SIGTERM exit), and even with another *live* duplicate in `-p` mode.
- A transcript-absent id never collides.
The only way real clide hit "already in use": the **old process was still alive** at respawn. `ClaudeSessionOrchestrator.close()` called `conversation.dispose()` (unawaited) and `ClaudeStreamJsonProcess.kill()` sent SIGTERM **without awaiting `exitCode`** — so `/clear` deleted the transcript and respawned on the same deterministic `--session-id` while the old `claude` was still alive (and re-flushing its transcript). 2.1.177 then rejects the id → exit 1.
**Fix shipped (chose await-death over the ticket''s tentative "mint a fresh id").** Awaiting the old process''s real death is the targeted root-cause fix and *preserves T-268''s deterministic-id continuity* (fresh-id would have required persisting a per-repo current-id and changing the continuity model). Changes:
- `stream_json_session.dart`: `kill()` now awaits `exitCode` (SIGTERM → 2s → SIGKILL → await); `dispose()` is idempotent (cached future) so the conversation''s unawaited dispose and the orchestrator''s awaited one share one teardown; new `SessionEnd.reason` getter (last non-empty stderr line, capped).
- `session_orchestrator.dart`: `close()` awaits `session.dispose()` → returns only once the process is truly dead, so the transcript clear + respawn happen after death.
- `claude_pane.dart`: `_onSessionEnd` surfaces `end.reason` in the status line — no more opaque "code 1" (fix #3).
- `session_naming.dart`: corrected the stale `clearSessionTranscript` doc (the real sidecar is the shared `memory/` dir, not `<id>/`; left untouched) + the await-death precondition.
- Tests: `close()` blocks until process exit (gated fake); `SessionEnd.reason` extraction/cap/empty.
**Folded-in CLI 2.1.177 re-probe — done, no code change needed.**
- `~/.claude/sessions/<pid>.json` registry characterized: PID-keyed, carries `{sessionId, cwd, version, kind:"interactive", entrypoint:"sdk-cli", status}`; written at start, **removed on a SIGTERM/clean exit**. So once clide awaits death, the id is free.
- Init cache (`init-<version>.json`) auto-refreshes at runtime per version — nothing to hand-edit.
- Probed 2.1.177 advertised `slash_commands` (31): no drift that breaks the routing table — none of clide''s `kTuiOnlyCommands` became advertised, and new entries (skills/builtins) correctly fall through to `forward`.
**Verification status.** Unit-tested (the teardown ordering + reason surfacing) and validated against the live CLI via probes. `make test` green. NOT yet exercised in the running GUI (would need `make run` + an interactive `/clear`) — recommend a quick live confirm before closing.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-15 11:12:36', '2026-06-15 15:59:15', NULL, '6f6aef16d3596682edc2c42d10fc4226', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.
---
**Implemented 2026-06-15.** One shared resolver `lib/src/env/shell_env.dart`:
- `primeLoginShellPath()` — probes `$SHELL -l -c` once at startup (sentinel-framed, 4s timeout, graceful fallback to the process PATH on Windows/empty-SHELL/non-zero/timeout/spawn-failure). Primed in `main.dart`''s `!kIsWeb` boot.
- `expandToolPath()` — the canonical user/local-dir merge (moved from toolchain_paths, which re-exports it for its tests).
- `resolvedToolPath()` — the single call every spawn site uses.
Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart` → `operations.dart`), the toolchain probe (`toolchain_paths.dart`), hosted claude (`agent_bootstrap.dart`). Deleted the macOS-only `env.dart` expander and the `cli_install.dart` copy — three expanders → one.
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-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 09:27:39', '2026-06-16 09:27:53', NULL, '01f87c62ce6ad7d1266d0acb10a38d01', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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 <path> (quickOpen.open(seed:) — new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:42:34', NULL, '1c0b6cd5dc6d81a9b4deb0f35a48e700', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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 <path> (quickOpen.open(seed:) — new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'review', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:42:39', NULL, 'e320e4acb9a1a9100e39ebf8199e72d2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q 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 <path> (quickOpen.open(seed:) — new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'done', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:56:18', NULL, 'aec7002f9cec6777eafbfdf163d1d4c3', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap — only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle — so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`→workspace.tab.next and `g shift+t`→workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntent→commands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it — but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`→workspace.tab.next, `g shift+t`→workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` — same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now — needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)
--- PROGRESS (2026-06-13) ---
PART 1 DONE (commit on main): workspace.tab.next / workspace.tab.previous cycle commands with wraparound + ctrl+pagedown/ctrl+pageup defaultBindings across every preset. Tests in test/builtin/default_layout/widget_test.dart.
PART 2 (gt/gT) STILL OPEN — and harder than the coordination note assumed. T-404 landed a global multi-chord matcher (root_shell), BUT it deliberately only STARTS a sequence on a MODIFIED chord (ctrl+w). Bare-key prefixes (g) are left editor/pane-local on purpose — otherwise the global matcher would steal `g` before the editor sees it, breaking gg/dd. So gt/gT (bare g) CANNOT just ride the global matcher. Options for part 2: (a) the editor/pane matchers grow gt/gT and dispatch command:workspace.tab.* (but then gt only works when the editor/a pane is focused, not globally); (b) a focus-agnostic bare-g disambiguation (g g = local docStart vs g t = global tab) — needs the global matcher to tentatively grab bare g AND coordinate with the editor''s matcher, which is the exact conflict T-404 avoided. Decide before building. The ctrl+pagedown/up commands already give every preset tab-cycling; gt/gT is a vim-affordance nicety on top.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-16 10:07:56', NULL, 'e0d10776a3893b8bc3cf5a8e13a2976e', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w …, g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift — and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST — it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path — land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet — T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) — coordinate the shared-prefix matcher tests.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-16 10:08:00', NULL, '90921bced174269b827a991909e792bd', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-16 10:08:46', NULL, '0791f81082715db7fd0bad2d2eb226e4', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-16 10:09:07', NULL, 'ad0504d2e30938d0597029852df3e3aa', 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 ('06FCRERT7X5WMZSGKQCA6T0VB4', 'task', '06FB0TNQM4W194B2421P2SF83R', 'Build + vendor tree-sitter.dll for Windows x64', 'Split from T-25 (cross-OS libtree-sitter build). Narrow, immediately-useful slice: get tree-sitter syntax highlighting working on Windows now, ahead of the full reproducible-CI cross-compile story.
**Current state (verified 2026-06-15):** The FFI loader already selects ''tree-sitter.dll'' on Windows (lib/kernel/src/syntax/tree_sitter_ffi.dart:261), but NO Windows binary is vendored — git ls-files shows the only tree-sitter artifact in the repo is native/linux-x64/libtree-sitter.so. native/windows-x64/ contains only clide.exe (the CLI client). Result: on Windows the loader hits the null / lastOpenError path and tree-sitter highlighting silently does not load (plain-text fallback). Same gap exists for macOS .dylib, tracked separately under T-25.
**Scope:** build tree-sitter v0.26.8 (tag v0.26.8, commit cd5b087cd9f45ca6d93ab1954f6b7c8534f324d2 — same pin as the Linux build) for x86_64-windows with TREE_SITTER_FEATURE_WASM=ON and wasmtime statically linked, producing tree-sitter.dll. Vendor it at native/windows-x64/tree-sitter.dll, alongside a native/windows-x64/BUILD.md build record mirroring native/linux-x64/BUILD.md (source repo, version, commit, build command, MSVC/toolchain, wasmtime version + static-link note, exports, SHA-256, binary size). Add/extend the assets/licenses.yaml entry so the Windows artifact + wasmtime are documented (D-42). Ensure the Windows Flutter bundle places the DLL where DynamicLibrary.open finds it (bundle dir / next to clide.exe — loader already probes ''$exe'' and ''$exe/lib'').
**Acceptance:**
- native/windows-x64/tree-sitter.dll committed; SHA-256 recorded in native/windows-x64/BUILD.md.
- On Windows, TreeSitterLib.init() succeeds (lastOpenError == null) and a code file renders with tree-sitter highlighting (not plain-text fallback).
- licenses.yaml documents the Windows tree-sitter artifact + statically-linked wasmtime.
- DLL is included in the make build-windows / Windows bundle output and loads from the bundled path.
**Out of scope:** CI reproducibility, macOS .dylib cross-compile, and the wasmtime NOTICE-file vendoring — those stay on the T-25 umbrella. This ticket is the hand-built Windows DLL to unblock highlighting locally now. Once T-25''s CI cross-compile lands, the Windows target folds into it.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 17:01:25', '2026-06-15 17:01:25', NULL, '9965c3d1b894e3af420f2e625b46efc4', 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 ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'epic', '06FB0TNQM6T6580D8ABDVTMNZW', 'Settings UI (builtin.settings-ui) — schema-driven settings panel', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:07:54', '2026-06-16 11:07:54', NULL, 'd1d3289581aa33cf921259239f64596d', 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 ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'epic', '06FB0TNQM6T6580D8ABDVTMNZW', 'Settings UI (builtin.settings-ui) — schema-driven settings panel', 'Implement the schema-driven settings UI (`builtin.settings-ui`) designed in T-302. Wireframes (JSON source + PNG): `docs/design/wireframes/settings/` — settings-screen (Editor), settings-search, settings-claude, settings-appearance.
**Form factor / IA (decided in T-302):** a centered MODAL overlay over the dimmed app (modal* / modalOverlay* tokens; close ✕ + Esc), opened by a `settings.open` command. Left category rail + a cross-category search box; right scrolling panel of CARDED sections — each logical group is a card (surface fill + 1px border, ~6px corners) with a small-caps section label + count above it. See the new `ui-design` `surface.md` → "Settings & grouped lists — sectioned cards".
**Engine, not per-screen UI:** field rows render from the schema each subsystem registers against the kernel SettingsStore — toggle / enum-select / text-number / "opens external file" affordance — each with label, help, default, reset, and a per-field SCOPE TAG (folder = project `.clide` / globe = always `~/.clide` / circle-dashed = default-unset; colour + tooltip; tap → scope menu). Edits write back to the resolved scope''s settings file. Categories are DATA; the only custom control is Appearance''s theme picker.
**Decisions / consumers:** kernel SettingsStore schema; WidgetsApp-only tokens (D-7); keymap presets (T-115 / T-64 / T-65 / T-66); editor settings (T-290 / T-29); activity fold (T-183); theming (existing builtin.theme-picker, D-44 / D-69); Claude config mirrors `lib/builtin/claude/src/meta_sidebar/config_tab.dart` (T-414 / T-183). Phosphor glyphs resolve via `byName(...)` (T-314) — no consts to add.
**Children — build infra first, then categories extrapolate:**
Infra: modal shell + `settings.open`; category rail + nav; schema field renderer (the engine); scope-tag control + scope resolution; cross-category search.
Categories (register a schema, reuse the engine): Editor (T-290), Keymap, Appearance (theme-picker control), Activity, Terminal, Extensions, Claude (mirror the sidebar Config panel).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:07:54', '2026-06-16 11:13:12', NULL, '7d72157d0bac885363cb1f3c5781ff3d', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings modal shell + settings.open command + overlay', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:16:20', '2026-06-16 11:16:20', NULL, 'a824ba240ccb186486421d8eac08f8ba', 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 ('06FD09DH88J1659FDDYJ83JH1M', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Slash-command typeahead intermittently fails to open on /', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:16:31', '2026-06-16 11:16:31', NULL, '679eb2ed603d98f47a40d2fff3c88108', 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 ('06FD09DH88J1659FDDYJ83JH1M', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Slash-command typeahead intermittently fails to open on /', 'User report 2026-06-16: typing `/` in the Claude composer **intermittently** does not open the slash-command typeahead dialog. Non-deterministic — sometimes it opens, sometimes the `/` just types with no popover.
Two hypotheses to distinguish (the fix depends which):
1. **Handler not triggering on `/`** — the composer''s keypress/text-change detection that should fire the typeahead misses the `/` (focus/IME race, the `/` not at a word boundary, the listener not firing on that change). If so, the popover never opens regardless of list contents.
2. **Empty/incomplete list** — the handler fires but the command list is empty or still loading (the slash-command set from the CLI probe / routing table not yet populated → typeahead opens with nothing and dismisses, or suppresses on empty). If so, it correlates with session warm-up / probe timing.
**Diagnosis first:** add a log at the trigger point (did `/` fire the open?) and at list-resolution (how many entries?) to see which path fails when it reproduces. Likely correlates with session/probe readiness (intermittent → timing).
**Files:** lib/builtin/claude/src/claude_composer.dart (the composer + typeahead trigger on `/`), lib/builtin/claude/src/slash_commands.dart (the command set / routing table, kClideOwnedCommands / kTuiOnlyCommands), the ClideTypeahead primitive (slash typeahead was migrated onto it). Compare with the `@`-mention typeahead (at_commands.dart), which shares the primitive — does `@` reproduce the same intermittency? That isolates primitive vs slash-specific.
**Acceptance:** `/` reliably opens the typeahead on every invocation (cold session + warm); if the list isn''t ready yet, open with a loading/empty state rather than silently not opening; a regression test around the trigger + empty-list path.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:16:31', '2026-06-16 11:17:44', NULL, '4cd213bf992066080ce6e54585aa701e', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings modal shell + settings.open command + overlay', 'Centered modal over the dimmed app using modalSurface/modalOverlay tokens (D-7, no Material); new settings.open CommandContribution (palette + menu/hat entry); close button + Esc dismiss; hosts the category rail + scrolling carded panel; mounted in root_shell like the other overlays. Wireframe: docs/design/wireframes/settings/settings-screen.png (modal shell).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:16:20', '2026-06-16 11:17:54', NULL, '89e4286fb5124eaac323f15255b62170', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-16 11:18:10', NULL, 'f2bf5ab4dfae8a7cc33e7a0ceb5c057c', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-16 11:18:16', NULL, 'f35a864ea275616c0cf8e045d4fb5fd7', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-16 11:18:20', NULL, '1711db6c6efda82a219d4c385e342380', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-16 11:18:27', NULL, 'ecf41b355896513f5edd509b67c3b859', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-16 11:18:32', NULL, '0c3fa39331b62551b77e07a5ac7eebe8', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-16 11:18:41', NULL, '74e604297688044c907da7bd19b0a101', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-16 11:18:45', NULL, '795846d24468f420c16e0ea5dbec175b', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-16 11:18:51', NULL, '6b361bb70ed3915bf53a7c7477cc8cba', 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 ('06FB0TNQM44CKN5QQGFKP1KWZC', 'story', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Edit editor settings (.editorconfig) from the settings panel', 'Write path for editor settings (T-29 follow-up). The editor now READS .editorconfig into a source-agnostic EditorSettings model (lib/src/editor/editor_settings.dart) and resolves it via editor_settings_resolver.dart. Add a settings-panel surface to EDIT these values and persist them — initially by writing the project .editorconfig (indent_style/size, end_of_line, max_line_length, trim_trailing_whitespace, insert_final_newline), later possibly a clide-owned settings file that merges as a higher-precedence source in resolveEditorSettings. On save the registry already re-resolves open buffers (editor.settings-changed), so the panel edits apply live. Respect EditorConfig semantics (sections/globs); start with the [*] section for the workspace defaults. Refs: T-29, D-88, editor_settings.dart, editor_settings_resolver.dart.', 'backlog', 'medium', NULL, NULL, 'D-88', '2026-06-09 07:15:14', '2026-06-16 11:19:03', NULL, '9728be42a0f8f9cd0cbf2563b3dd7555', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-16 11:19:11', NULL, '3530c732f928f84a36e8ac450880835d', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-16 11:19:35', NULL, '7eba4a1e2a613734facc67e035f6ac08', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-16 11:19:41', NULL, '02e42ddbdc8109d08129c224b097cc4b', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-16 11:19:47', NULL, '6939e563a1bd65e6ea4a2dd370358b27', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-16 11:19:57', NULL, 'bedd84023b232dc391ff4fbbce771f8a', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-16 11:20:21', NULL, '9cbd66575a1785399eb3c1898720f6b9', 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 ('06FD0ABC7QNEC3XCTV73YPSGR4', 'task', NULL, 'Plumb in Claude Code''s remote-control feature', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:20:36', '2026-06-16 11:20:36', NULL, '4dd8daefbdbf993ef612b3e4fb74d67a', 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 ('06FD0ABC7QNEC3XCTV73YPSGR4', 'task', NULL, 'Plumb in Claude Code''s remote-control feature', 'User report 2026-06-16: Claude Code''s **remote-control** feature (start / monitor / steer a running Claude Code session remotely — e.g. from claude.ai or the mobile app) is **not plumbed into clide**.
clide hosts the `claude` CLI as a stream-json child (`--session-id` / `--resume`, D-77). For remote control to work through clide, a clide-hosted session needs to be discoverable + steerable by the remote-control surface the same way a terminal-launched `claude` is.
**Step 1 — characterize (don''t assume the mechanism):** determine exactly what Claude Code exposes for remote control on the running CLI version. Likely touch points:
- the `~/.claude/sessions/<pid>.json` live-session registry we characterized in T-437 (PID-keyed: sessionId, cwd, version, kind, entrypoint, status) — is this what the remote-control surface enumerates? clide-spawned sessions DO write an entry (we saw `entrypoint: sdk-cli`). Confirm whether clide''s entry is visible/controllable remotely, or whether `entrypoint: sdk-cli` / headless spawn excludes it.
- any flag / capability in the `initialize` stream-json probe (T-411/T-151 cache) advertising remote control.
- whether enrollment requires the interactive TUI (and so is suppressed under clide''s stream-json spawn).
**Step 2 — plumb:** wire clide so a clide-hosted session participates in remote control (register correctly / pass the needed flag), and surface its state in the UI (e.g. an indicator that the session is remote-controllable / being driven remotely). If it conflicts with clide''s session ownership (pane pins a session-id, T-156), document the boundary.
**Open question:** is the goal (a) clide sessions become remotely controllable from claude.ai/mobile, or (b) clide can act as a remote controller of other sessions? Default read is (a). Confirm with the user before building.
Related: D-77 (stream-json session model), T-437 (sessions registry characterization), T-156 (clide owns session lifecycle), T-411 (capability probe).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:20:36', '2026-06-16 13:20:17', NULL, '0587e7b599ca9a2a96594172eeb9cffb', 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 ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Terminal settings category', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:25', '2026-06-16 13:20:25', NULL, '9a4be93680d3b74b4776993d9130437f', 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 ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Terminal settings category', 'Register the terminal settings schema (font family/size, scrollback, cursor style, bell, copy-on-select, etc.) as standard toggle / number / select fields via the field renderer (T-448).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:25', '2026-06-16 13:20:34', NULL, '0e3ee0e73ee8c060bb7639a12097ebbb', 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 ('06FD15TY946689VZK3MNABSMDW', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Extensions settings category', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:41', '2026-06-16 13:20:41', NULL, '8d6553a634106c3acd075eb95b9d53a6', 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 ('06FD15TY946689VZK3MNABSMDW', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Extensions settings category', 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:41', '2026-06-16 13:20:47', NULL, '8eb78852a5c8f1e22b5afa99706cbfdb', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-16 13:20:52', NULL, '33510004b4584821ccf0c368f76b3235', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-16 13:21:00', NULL, '70d5a923985efd727ca999489962e7d0', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'task', '06FB0TNQM6T6580D8ABDVTMNZW', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation.
**Deliverable**
- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review.
- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete.
**Scope to frame (from T-8)**
- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`.
- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?).
- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290).
- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI).
**Open design questions for the wireframe to answer**
- Settings as a full-screen view, a pane/tab, or a modal?
- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched?
- Search/filter across all settings.
- How schema-driven fields render labels, help text, defaults, and reset.
**Constraints**
- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino).
This is the design step; implementation of the actual settings UI is separate child work under T-8.
## Scope-tag icon decision (review, 2026-06-10)
The per-field scope tag is an ICON, not a text label (space + cognitive load).
Chosen trio (location→global reach ladder), keeping the colour coding + a
tooltip with the word for clarity/a11y; tapping opens the scope menu:
- Project (this repo) = `folder` 0xe24a (teal) — already wired
- Always (all clide) = `globe` 0xe288 (amber) — add const
- Default (unset) = `circle-dashed` 0xe602 (grey) — add const
Alternatives considered: house (warmer home↔world pair), user-circle (mirrors
the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
consts need adding. Preview/picking is blocked on a native glyph card (see the
new ticket) — goldens render the font as Ahem boxes.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-16 13:21:11', NULL, '1db1af71519321792537391a5f38b613', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor hardens EOL/EOF fidelity to stop cross-platform commit churn', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, 'ba4504b18aed43d841f9eb57d5beae5a', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor hardens EOL/EOF fidelity to stop cross-platform commit churn', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.
## Problem
The editor can introduce spurious EOL (LF↔CRLF) and EOF (trailing-newline) changes on save, producing cross-platform commit churn — a "race" where Linux/macOS and Windows contributors repeatedly flip a file''s line endings or final newline back and forth in commits even when they made no real edit to those lines.
## Current state / root cause
- **Read path** — `lib/src/editor/registry.dart:50` loads via `File.readAsString()` and stores the buffer as a plain string in `EditorBuffer` (`lib/src/editor/buffer.dart`). It records **neither** the original EOL style (LF / CRLF / CR) **nor** whether the file ended with a trailing newline.
- **Save path** — `registry.dart:141` calls `EditorSettings.applyOnSave` (`lib/src/editor/editor_settings.dart:114`). That method only rewrites EOLs / final-newline when `.editorconfig` explicitly sets `end_of_line` / `insert_final_newline`; otherwise it returns the content unchanged. So byte-fidelity depends entirely on the buffer + text-editing widget round-trip faithfully preserving `\r\n` and the EOF state. Because the original state is never recorded on read, there is **no way to restore it** if any layer (the Flutter text controller / platform text input in `syntax_text_controller.dart` / `editor_controller.dart`) normalizes newlines.
- **No `.gitattributes` backstop** — the only entry is `.pql/changelog/*.sql merge=union`; there is no `* text=auto` / `eol` rule. Whatever the editor writes lands verbatim in commits with no normalization safety net.
## Proposed hardening
1. On read, detect and record on the buffer: (a) dominant EOL style, (b) whether the file has mixed EOLs, (c) presence/absence of a final newline.
2. On save, default to **preserving** the recorded EOL + EOF state when `.editorconfig` does not override (explicit `.editorconfig` settings still win). Mixed-EOL files preserved as-is unless explicit normalization is requested.
3. Audit the text-editing round-trip (`syntax_text_controller.dart`, `editor_controller.dart`) for silent `\r` dropping or final-newline mutation; if found, normalize at the buffer boundary on read and restore on save.
4. Add a repo `.gitattributes` (`* text=auto eol=lf` plus binary carve-outs) as defense-in-depth so commits do not churn even if the editor slips.
5. Tests: round-trip CRLF, LF, no-final-newline, and mixed-EOL fixtures through open → edit → save and assert byte-identical output absent explicit settings. Exercise the Linux/macOS/Windows save paths.
## Acceptance
Opening a file and saving it with no content edits produces a **byte-identical** file (no EOL/EOF diff) on Linux, macOS, and Windows regardless of the file''s original line endings — unless `.editorconfig` explicitly requests normalization.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:12:04.212', '2026-06-16 14:12:09.834', NULL, 'e5f4a3e6dc5b9c8977ef36d0a17f2b9e', 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 ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, 'ec93569d2ab51868382e04dc7711980b', 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 ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.
## Repro
In the main column (the center "workspace" slot), the editor renders as a **top split** above the primary pane (Claude). Close the last editor panel/buffer in that top split (e.g. `:q`, `ctrl+w`, or closing the last editor tab).
- **Expected:** the top split collapses and the remaining primary pane takes the full column height.
- **Actual:** the top split does not collapse cleanly — leftover/empty space or broken layout remains where the editor split was.
## Suspect code paths (root cause not yet confirmed)
- `lib/src/shell/slot_host.dart` — `_WorkspaceSlot.build()` (~lines 147-206). The split is computed asymmetrically: the **top** region is dynamic — `topTab = reveal ?? (editorOpen ? editorTab : null)` (~line 176), collapsing only when `topTab == null` (~line 178) — while the **bottom** region is always `primaryPane = (claude ?? active)`. The top/bottom asymmetry is the prime suspect for why the top split specifically fails to collapse.
- `lib/kernel/src/panels/arrangement.dart` — `closeEditor()` (~line 108) flips `editorOpen = false` but does not switch the workspace slot''s **active tab** away from `editor.active`. The editor tab stays registered, so a rebuild may still resolve `active`/`editorTab` to the editor and keep the split open.
- `lib/builtin/editor/src/extension.dart` — (~lines 36-43) the `editor.active-changed` handler calls `ctx.arrangement.closeEditor()` when the last buffer closes (`id == null`). Confirm this fires and that the resulting rebuild actually reaches the collapse branch.
- Possible rebuild-notification gap: `_WorkspaceSlot`''s `ListenableBuilder` listens to `kernel.arrangement`; verify active-tab changes (`kernel.panels`) also drive the rebuild that recomputes `topTab`.
## Investigation / fix direction
1. Reproduce in the running app and pin down which of the above is the actual cause (asymmetric top/bottom split logic vs. active tab not switching away from the closed editor).
2. Make collapse symmetric and state-driven: when the editor top split has no remaining panel, the region must drop out so the primary pane fills the column — independent of which tab is "active".
3. On editor close, ensure the workspace active tab is moved off `editor.active` (or that the split renderer ignores a closed editor tab regardless of active state).
## Tests
Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-16 14:15:54.344', NULL, '50f4b7916e89ac7e0804ae6669940e90', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 07:39:25.019', NULL, 'ad0c79b26f1b2c9dbdc9983daf7cb3ef', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
- Each option renders its label **in its own face** as an inline preview.
**Bake in Inter as the default UI font.**
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
**Font registry + wiring (the real work):**
- A small curated registry of bundled faces per role (UI: Inter + 12 others; mono: JetBrainsMono + 12 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
**Acceptance:**
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 07:39:55.628', NULL, '0bd5fdb6f8d924a23eb45be2c48bedaf', 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 ('06FD91RWDWZPHFPJ72FHHHWS08', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'API-error cards: flag with a red error border (statusError)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:41:24.975', '2026-06-17 07:41:24.975', NULL, '38eac435cdb43c08db84de486c65a527', 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 ('06FD91RWDWZPHFPJ72FHHHWS08', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'API-error cards: flag with a red error border (statusError)', 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781681999949.png). An API error renders as a plain conversation card with no error styling:
clide
API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited
It should be visually flagged with a **red error border** on the card so it stands out from normal turns.
**Already supported — just needs wiring.** `lib/builtin/claude/src/conversation_card.dart`:
- `ConversationCardStatus.error` exists (line ~302 → `tokens.statusError`) but today only tints the header status tick, not the border.
- The `bordered` variant draws `Border.all(color: widget.borderColor ?? tokens.panelBorder)` (lines ~61 / ~224) — so the border defaults to the neutral `panelBorder`.
**Fix:** render the API-error card as `variant: bordered, status: error, borderColor: tokens.statusError` (or, cleaner, have the card derive the border from `status == error → tokens.statusError` so any error card gets the red border without each caller passing it). Wire the producer — the error / system message that carries the "API Error …" text (in `conversation_view.dart` / `transcript_reader.dart`, e.g. the stream error / `SessionEnd.reason` path) — to mark the card as an error.
**Acceptance:** an API error (rate-limit, server error, etc.) renders a card with a red `statusError` border; normal cards unchanged; a widget test asserts the error card''s border colour.
**Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:41:24.975', '2026-06-17 07:41:47.266', NULL, 'e4c3db66fb4ba42ba08896b97ef3ad89', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings modal shell + settings.open command + overlay', 'Centered modal over the dimmed app using modalSurface/modalOverlay tokens (D-7, no Material); new settings.open CommandContribution (palette + menu/hat entry); close button + Esc dismiss; hosts the category rail + scrolling carded panel; mounted in root_shell like the other overlays. Wireframe: docs/design/wireframes/settings/settings-screen.png (modal shell).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:16:20', '2026-06-17 09:21:55.041', NULL, '82094d971c514c6524dd0c2197d171a4', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 09:50:39.449', NULL, '6511e1989b01d0928e9128d691d7725c', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 09:50:52.176', NULL, '2e229c01974646fbb2c44e746d36171c', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 09:50:52.665', NULL, '3e2912b50d8c6791faadbedae162f53b', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 09:50:53.074', NULL, 'c8b9f21f3a69d28376846b3b6c9e6ecd', 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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'epic', NULL, 'i18n coverage: route hardcoded UI labels through the catalog', 'D-21 makes i18n a Tier-0 contract: "All user-facing strings resolve through a namespaced i18n catalogue loader." In practice coverage is ~7%. A June 2026 audit found:
- Only 2 of 30 builtin extensions actually call .string()/.interpolated(): builtin.settings-ui and builtin.theme-picker. Total live i18n call sites: 17.
- ~144 hardcoded user-facing string literals (Text/ClideText, tooltip:, hintText:, dialog/toast text) bypass i18n across the other 28 extensions + lib/widgets + lib/kernel.
- 12 of the 14 existing catalogs are effectively orphaned: the strings are pre-translated but the code hardcodes the label anyway (e.g. welcome ships a 10-key catalog AND 15 hardcoded strings; claude ships 10 keys + 35 hardcoded).
Because every call site and TabContribution supplies an inline English placeholder, nothing renders blank at runtime — so this is invisible in the running app. The cost is that ~93% of labels can never be localized: they never reach the catalog lookup. That is a Tier-0 (D-21) gap, not polish.
Prior work: T-155 (done) fixed the catalog plumbing + boot warnings and created tab.title catalogs; T-371 (backlog, high, parent T-359) fixes the a11y/i18n gate tests that under-enumerate namespaces. Neither migrates the hardcoded labels — that is this epic.
This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending the existing catalogs where present. T-371 should land alongside so the gate fails when a new namespace ships uncovered and the work cannot re-drift.
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4f70e7d5f578903c7ea33e488e5fa20a', 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 ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: fix concrete catalog gaps (theme-picker missing keys + diff has no catalog)', 'Two confirmed catalog-behind-code gaps found in the June 2026 audit. Small, isolated fix — do this first.
1) builtin.theme-picker requests two keys that are NOT in lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json, so they silently fall through to the inline placeholder:
- section.appearance (lib/builtin/theme_picker/src/settings_view.dart:75)
- toggle.highContrast (lib/builtin/theme_picker/src/settings_view.dart:83)
Add both keys with their current placeholder text as the translation.
2) builtin.diff contributes a TabContribution with titleKey ''tab.title'' + i18nNamespace ''builtin.diff'' (lib/builtin/diff/src/extension.dart) but ships NO catalog file. There is no lib/kernel/src/i18n/catalog/builtin.diff_en_us.json. Create it with tab.title (= ''Diff'', matching the placeholder).
Acceptance: both theme-picker keys resolve from the catalog (not the placeholder); a builtin.diff catalog exists with tab.title; no ''missing key'' i18n warnings on boot for these namespaces.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, '9716086bea4e0317f6be4948f132d633', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.claude UI labels to the catalog (~35 strings)', 'Largest single offender in the audit: ~35 hardcoded user-facing strings in builtin.claude bypass i18n, despite builtin.claude_en_us.json already existing (currently tab.title + 4 orphaned status.* keys).
Route the hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(key, namespace: ''builtin.claude'', placeholder: <current literal>), and add the keys to the catalog. Hotspots from the audit include lib/builtin/claude/src/prompt_card.dart (e.g. :314 ''Review your answers'', :372 ''Review'') and the permission/AskUserQuestion interaction widgets — sweep the whole extension, not just those lines.
Watch the interaction-zone widgets (D-78): keep them display-only; the i18n change is label-only, no behavioural change.
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/claude/ (single-char glyphs like ''<'' / ''+'' used as icons are exempt — note any left); all new keys present in the catalog; existing orphaned status.* keys either wired up or removed.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, '661934ec63b9365ac504af912d421b5b', 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 ('06FDA0X5WFME83GS0DKYG3WVJ8', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.welcome UI labels to the catalog (~15 strings)', 'builtin.welcome ships a full 10-key catalog (title, subtitle, open-project, open-project.hint, etc.) that is entirely orphaned — the view hardcodes ~15 strings instead. Wire the existing catalog up and add any missing keys.
Hotspots from the audit: lib/builtin/welcome/src/welcome_view.dart — e.g. :108 ''TIPS'', :173 ''START'', :258 ''RECENT'', plus the title/subtitle/open-project block the catalog already has translations for.
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/welcome/; the pre-existing builtin.welcome catalog keys are actually consumed (no orphans); section headers added as keys.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, 'cd2edbe49bfb6af002467e5a7da82abe', 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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate git + diff panel labels to the catalog (~16 strings)', 'builtin.git (~9 hardcoded) and builtin.diff (~7 hardcoded) panel/view labels bypass i18n. builtin.git has a tab.title-only catalog; builtin.diff''s catalog is created by the gaps task (T-463) — extend both.
Hotspots from the audit:
- lib/builtin/git/src/git_status_item.dart:167 ''Switch branch'', :179 ''Loading…''
- lib/builtin/git/src/git_panel_view.dart:94 ''Nothing to commit, working tree clean.''
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, '1b085e7af9f7720886f23f25a2dc8280', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a5e3b32ca26332d6bf7fbcfb2c52555e', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate remaining extension labels — menubar, output, search, deeplink, editor, files, terminal, markdown, vim (~24 strings)', 'Tail of the audit: the remaining extensions with hardcoded labels. menubar ~6 (no catalog), output ~4 (no catalog), search ~3 (no catalog), deeplink ~3 (no catalog), editor ~1, files ~1, terminal ~1, markdown ~1 (no catalog), vim ~1 (no catalog).
Several have no catalog file yet — create lib/kernel/src/i18n/catalog/builtin.<ext>_en_us.json for menubar, output, search, deeplink, markdown, vim. editor/files/terminal already have catalogs (with orphaned keys) — wire their hardcoded literals up and consume the orphans where they match.
Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place.
Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'ff32568083c6143d7ea8472c204cbb6e', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: route framework-level labels in lib/widgets + lib/kernel (~10 strings)', '~9 hardcoded user-facing strings in lib/widgets/ and ~1 in lib/kernel/ live outside any extension, so they have no obvious i18n namespace. Resolve the namespace question, then migrate.
Hotspots from the audit:
- lib/widgets/src/ex_line_overlay.dart:163 '':'' , :183 ''Not an editor command'' (these are vim ex-line — may belong to builtin.vim''s namespace rather than a generic one)
- lib/widgets/src/multitab_pane.dart:358 ''+'' (likely an icon glyph — exempt if so)
Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt — note any left.
Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'backlog', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, 'f284696fc306e1e2452f89c5fa8b7057', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 10:06:59.713', NULL, 'f0a3bc1d6ab084ce36fb529cadca2d52', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:07:00.144', NULL, '67beda85d5d212fb1f3493e575a2d338', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:09:41.725', NULL, 'ccd735c9480310dd9a849881c39ce100', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:09:41.911', NULL, 'db4e636e73d5ca1486f9416f47515e03', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:16:22.353', NULL, 'a8d1080773ce726edebb44a0d2d56023', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:16:22.701', NULL, 'e8b9bc4e621f5f39d6bba4904eadf0ac', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:22:42.544', NULL, 'a10a68df4d4cf7ed0f639d74dda41d37', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-17 10:29:07.736', NULL, 'a9ef8003e03def2e2ba4c1f59fbba07f', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-17 10:31:23.935', NULL, 'a7ddd9362097e6a2f7df764faf7cbf79', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-17 10:44:25.018', NULL, 'c5d65c432709c0ffad00237e82b7a23c', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-17 10:48:30.719', NULL, '4678d116aadfaea65526200caf8e0ece', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-17 10:56:30.288', NULL, 'e7d925e921ee3c791398c6b15ebc00f5', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-17 11:04:11.562', NULL, 'e3bf96df622cc32d69465ac3ec06d269', 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 ('06FD15TY946689VZK3MNABSMDW', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Extensions settings category', 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:41', '2026-06-17 11:21:19.581', NULL, '70ec508d70d32c037cbe69b24a280f52', 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 ('06FD15TY946689VZK3MNABSMDW', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Extensions settings category', 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:41', '2026-06-17 11:43:37.614', NULL, '09f569e8ad465363d95821413ff7d027', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:08:37.084', NULL, 'e7198c66868b8d602168083dcd40dda9', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:09:28.013', NULL, '8c8ad599dcbe3b500f710b5b03b0c1fb', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:11:21.874', NULL, 'aa269d123343566d0025e9eee2662ab4', 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 ('06FDB1VFADV4QK1YG29T658T8C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude defaults: ''apply to current sessions?'' prompt on change', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 12:21:23.411', '2026-06-17 12:21:23.411', NULL, '924eef1841e09a2a49c1c39d9bfc1331', 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 ('06FDB1VFADV4QK1YG29T658T8C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude defaults: ''apply to current sessions?'' prompt on change', 'Follow-up to T-457. The Claude settings category sets new-session defaults (model/effort/permission) via generic schema selects — those persist + seed NEW sessions but don''t touch running ones. Add the ''Claude settings changes detected — apply to current sessions?'' prompt: on a default change with ≥1 running session, confirm via DialogRouter, then apply to all sessions through the existing builtin.claude/command channel (/model, /effort, /permissions). Needs a custom control (the generic select can''t prompt), so swap the Claude category''s selects for a custom-control widget per D-? / the T-452 escape hatch.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 12:21:23.411', '2026-06-17 12:21:31.456', NULL, '61f297cc5f01d6f6e43e1fa64efd4875', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:21:39.311', NULL, '2f3f27095398a0a0e0240b2e0eac34c2', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
- Each option renders its label **in its own face** as an inline preview.
**Bake in Inter as the default UI font.**
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
**Font registry + wiring (the real work):**
- A small curated registry of bundled faces per role (UI: Inter + 12 others; mono: JetBrainsMono + 12 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
**Acceptance:**
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 18:04:55.886', NULL, '1a2523c3f827cd90a373427f2895a87c', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:11:42.049', NULL, '77cf08e7fff9bed103494aeeed8c7185', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:11:49.697', NULL, '2e4cf96e04c1be6ce99cac33c4350d3c', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
- Each option renders its label **in its own face** as an inline preview.
**Bake in Inter as the default UI font.**
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
**Font registry + wiring (the real work):**
- A small curated registry of bundled faces per role (UI: Inter + 12 others; mono: JetBrainsMono + 12 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
**Acceptance:**
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 18:14:38.272', NULL, '138f95c597eebc2db37c9e0c4bc49c91', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:18:13.607', NULL, 'c401f1c31a8bd9f8cb252f718696d07e', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '267cb357e1a5532db0a4bb0f9312ccf2', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:59:09.679', NULL, 'e81367cebfa243d563325587539f0a3c', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '0a090c8cf04b03de3b82f2e6ea3029de', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '273598190d1cafe66e693899852916e7', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', 'Per D-101. The 11 sites that need a BuildContext threaded from the nearest context-bearing caller (resolve ClideSettings.fonts.monoOf(context) and pass the family in): prompt_card.dart toolEditBody/toolReadLikeBody/toolPathLine (top-level fns); conversation_view.dart _toolResult; welcome_view.dart _tipRow; search_panel_view.dart _base getter; clide_markdown.dart _inlineElementSpan/_recordLinkSpan/_fileLinkSpan (static span chain). Until done, these render the default JetBrains Mono regardless of the mono-font setting.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 19:03:19.250', NULL, 'fb570bedf09fd60eb835f107310766d4', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', 'Per D-101. Migrate ClideTheme.of(context) consumers (hundreds of sites) to ClideSettings.theme.of(context). The facade already delegates to ClideTheme, so this is a mechanical, no-behavior-change sweep — do it in focused batches with analyze between, ideally via a scripted find/replace + import fixups.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:03:21.095', NULL, 'b9144ecc303e9223fe212b17ee79c9d8', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', 'Per D-101. Migrate i18n reads (ClideKernel.of(context).i18n[.string]) to ClideSettings.i18n.of(context). The facade delegates to the same I18n service, so behaviour is unchanged; mechanical sweep in batches.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:03:22.235', NULL, '16d6229039400bd826a77a08eca9573b', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', 'Per D-101. The 11 sites that need a BuildContext threaded from the nearest context-bearing caller (resolve ClideSettings.fonts.monoOf(context) and pass the family in): prompt_card.dart toolEditBody/toolReadLikeBody/toolPathLine (top-level fns); conversation_view.dart _toolResult; welcome_view.dart _tipRow; search_panel_view.dart _base getter; clide_markdown.dart _inlineElementSpan/_recordLinkSpan/_fileLinkSpan (static span chain). Until done, these render the default JetBrains Mono regardless of the mono-font setting.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 19:35:22.690', NULL, '7dd6dae4197fc95d0bc6becfad4eecbb', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', 'Per D-101. Migrate i18n reads (ClideKernel.of(context).i18n[.string]) to ClideSettings.i18n.of(context). The facade delegates to the same I18n service, so behaviour is unchanged; mechanical sweep in batches.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:37:39.813', NULL, 'fc64918991f0c9133419ee51227e1939', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', 'Per D-101. Migrate ClideTheme.of(context) consumers (hundreds of sites) to ClideSettings.theme.of(context). The facade already delegates to ClideTheme, so this is a mechanical, no-behavior-change sweep — do it in focused batches with analyze between, ideally via a scripted find/replace + import fixups.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:47:02.941', NULL, '8ea58b6162c81778a610f46b07585b05', 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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'bug', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Markdown prose ignores the UI-font setting (clideUiFamily const)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 20:44:11.501', '2026-06-17 20:44:11.501', NULL, '5861faa02d7cc34271dc72cf964f63cf', 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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'bug', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Markdown prose ignores the UI-font setting (clideUiFamily const)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-17 20:44:11.501', '2026-06-17 20:51:21.006', NULL, '6d53da1fcfe6ad27b54a6ea07c1ccc7e', 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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'epic', NULL, 'i18n coverage: route hardcoded UI labels through the catalog', 'D-21 makes i18n a Tier-0 contract: "All user-facing strings resolve through a namespaced i18n catalogue loader." In practice coverage is ~7%. A June 2026 audit found:
- Only 2 of 30 builtin extensions actually call .string()/.interpolated(): builtin.settings-ui and builtin.theme-picker. Total live i18n call sites: 17.
- ~144 hardcoded user-facing string literals (Text/ClideText, tooltip:, hintText:, dialog/toast text) bypass i18n across the other 28 extensions + lib/widgets + lib/kernel.
- 12 of the 14 existing catalogs are effectively orphaned: the strings are pre-translated but the code hardcodes the label anyway (e.g. welcome ships a 10-key catalog AND 15 hardcoded strings; claude ships 10 keys + 35 hardcoded).
Because every call site and TabContribution supplies an inline English placeholder, nothing renders blank at runtime — so this is invisible in the running app. The cost is that ~93% of labels can never be localized: they never reach the catalog lookup. That is a Tier-0 (D-21) gap, not polish.
Prior work: T-155 (done) fixed the catalog plumbing + boot warnings and created tab.title catalogs; T-371 (backlog, high, parent T-359) fixes the a11y/i18n gate tests that under-enumerate namespaces. Neither migrates the hardcoded labels — that is this epic.
This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending the existing catalogs where present. T-371 should land alongside so the gate fails when a new namespace ships uncovered and the work cannot re-drift.
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'ready', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-18 07:40:45.691', NULL, '81fd18dccd660abfa9d8ff5ff16dc119', 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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'epic', NULL, 'i18n coverage: route hardcoded UI labels through the catalog', 'D-21 makes i18n a Tier-0 contract: "All user-facing strings resolve through a namespaced i18n catalogue loader." In practice coverage is ~7%. A June 2026 audit found:
- Only 2 of 30 builtin extensions actually call .string()/.interpolated(): builtin.settings-ui and builtin.theme-picker. Total live i18n call sites: 17.
- ~144 hardcoded user-facing string literals (Text/ClideText, tooltip:, hintText:, dialog/toast text) bypass i18n across the other 28 extensions + lib/widgets + lib/kernel.
- 12 of the 14 existing catalogs are effectively orphaned: the strings are pre-translated but the code hardcodes the label anyway (e.g. welcome ships a 10-key catalog AND 15 hardcoded strings; claude ships 10 keys + 35 hardcoded).
Because every call site and TabContribution supplies an inline English placeholder, nothing renders blank at runtime — so this is invisible in the running app. The cost is that ~93% of labels can never be localized: they never reach the catalog lookup. That is a Tier-0 (D-21) gap, not polish.
Prior work: T-155 (done) fixed the catalog plumbing + boot warnings and created tab.title catalogs; T-371 (backlog, high, parent T-359) fixes the a11y/i18n gate tests that under-enumerate namespaces. Neither migrates the hardcoded labels — that is this epic.
This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending the existing catalogs where present. T-371 should land alongside so the gate fails when a new namespace ships uncovered and the work cannot re-drift.
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'in_progress', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-18 07:46:07.612', NULL, '28daeb118a8ceb5181723c33de18f71a', 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 ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: fix concrete catalog gaps (theme-picker missing keys + diff has no catalog)', 'Two confirmed catalog-behind-code gaps found in the June 2026 audit. Small, isolated fix — do this first.
1) builtin.theme-picker requests two keys that are NOT in lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json, so they silently fall through to the inline placeholder:
- section.appearance (lib/builtin/theme_picker/src/settings_view.dart:75)
- toggle.highContrast (lib/builtin/theme_picker/src/settings_view.dart:83)
Add both keys with their current placeholder text as the translation.
2) builtin.diff contributes a TabContribution with titleKey ''tab.title'' + i18nNamespace ''builtin.diff'' (lib/builtin/diff/src/extension.dart) but ships NO catalog file. There is no lib/kernel/src/i18n/catalog/builtin.diff_en_us.json. Create it with tab.title (= ''Diff'', matching the placeholder).
Acceptance: both theme-picker keys resolve from the catalog (not the placeholder); a builtin.diff catalog exists with tab.title; no ''missing key'' i18n warnings on boot for these namespaces.', 'in_progress', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:14.458', '2026-06-18 07:46:07.636', NULL, 'a950ae3e425a624a39254264c6f2cb5e', 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 ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: fix concrete catalog gaps (theme-picker missing keys + diff has no catalog)', 'Two confirmed catalog-behind-code gaps found in the June 2026 audit. Small, isolated fix — do this first.
1) builtin.theme-picker requests two keys that are NOT in lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json, so they silently fall through to the inline placeholder:
- section.appearance (lib/builtin/theme_picker/src/settings_view.dart:75)
- toggle.highContrast (lib/builtin/theme_picker/src/settings_view.dart:83)
Add both keys with their current placeholder text as the translation.
2) builtin.diff contributes a TabContribution with titleKey ''tab.title'' + i18nNamespace ''builtin.diff'' (lib/builtin/diff/src/extension.dart) but ships NO catalog file. There is no lib/kernel/src/i18n/catalog/builtin.diff_en_us.json. Create it with tab.title (= ''Diff'', matching the placeholder).
Acceptance: both theme-picker keys resolve from the catalog (not the placeholder); a builtin.diff catalog exists with tab.title; no ''missing key'' i18n warnings on boot for these namespaces.', 'done', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:14.458', '2026-06-18 07:56:10.133', NULL, '2d182b7c0f40848dc5a59771b73e4da1', 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 ('06FDA0X5WFME83GS0DKYG3WVJ8', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.welcome UI labels to the catalog (~15 strings)', 'builtin.welcome ships a full 10-key catalog (title, subtitle, open-project, open-project.hint, etc.) that is entirely orphaned — the view hardcodes ~15 strings instead. Wire the existing catalog up and add any missing keys.
Hotspots from the audit: lib/builtin/welcome/src/welcome_view.dart — e.g. :108 ''TIPS'', :173 ''START'', :258 ''RECENT'', plus the title/subtitle/open-project block the catalog already has translations for.
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/welcome/; the pre-existing builtin.welcome catalog keys are actually consumed (no orphans); section headers added as keys.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:26.627', '2026-06-18 13:41:29.322', NULL, '9ce7dc6dd92151c29617d26088c3c85c', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.claude UI labels to the catalog (~35 strings)', 'Largest single offender in the audit: ~35 hardcoded user-facing strings in builtin.claude bypass i18n, despite builtin.claude_en_us.json already existing (currently tab.title + 4 orphaned status.* keys).
Route the hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(key, namespace: ''builtin.claude'', placeholder: <current literal>), and add the keys to the catalog. Hotspots from the audit include lib/builtin/claude/src/prompt_card.dart (e.g. :314 ''Review your answers'', :372 ''Review'') and the permission/AskUserQuestion interaction widgets — sweep the whole extension, not just those lines.
Watch the interaction-zone widgets (D-78): keep them display-only; the i18n change is label-only, no behavioural change.
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/claude/ (single-char glyphs like ''<'' / ''+'' used as icons are exempt — note any left); all new keys present in the catalog; existing orphaned status.* keys either wired up or removed.', 'in_progress', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:20.180', '2026-06-18 13:52:52.920', NULL, '056dfb04194ffe61a7e2eb3102b88aa5', 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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate git + diff panel labels to the catalog (~16 strings)', 'builtin.git (~9 hardcoded) and builtin.diff (~7 hardcoded) panel/view labels bypass i18n. builtin.git has a tab.title-only catalog; builtin.diff''s catalog is created by the gaps task (T-463) — extend both.
Hotspots from the audit:
- lib/builtin/git/src/git_status_item.dart:167 ''Switch branch'', :179 ''Loading…''
- lib/builtin/git/src/git_panel_view.dart:94 ''Nothing to commit, working tree clean.''
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'in_progress', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-18 13:52:52.931', NULL, '0c8b2df9caa133215ac9c0b1b1a46296', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'in_progress', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-18 13:52:52.931', NULL, '6a60e0b3b5cc6a4b09b0b65a1bf05e79', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate remaining extension labels — menubar, output, search, deeplink, editor, files, terminal, markdown, vim (~24 strings)', 'Tail of the audit: the remaining extensions with hardcoded labels. menubar ~6 (no catalog), output ~4 (no catalog), search ~3 (no catalog), deeplink ~3 (no catalog), editor ~1, files ~1, terminal ~1, markdown ~1 (no catalog), vim ~1 (no catalog).
Several have no catalog file yet — create lib/kernel/src/i18n/catalog/builtin.<ext>_en_us.json for menubar, output, search, deeplink, markdown, vim. editor/files/terminal already have catalogs (with orphaned keys) — wire their hardcoded literals up and consume the orphans where they match.
Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place.
Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'in_progress', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-18 13:52:52.932', NULL, '3313d03f3d8428d61aa3528c2f4371db', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.claude UI labels to the catalog (~35 strings)', 'Largest single offender in the audit: ~35 hardcoded user-facing strings in builtin.claude bypass i18n, despite builtin.claude_en_us.json already existing (currently tab.title + 4 orphaned status.* keys).
Route the hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(key, namespace: ''builtin.claude'', placeholder: <current literal>), and add the keys to the catalog. Hotspots from the audit include lib/builtin/claude/src/prompt_card.dart (e.g. :314 ''Review your answers'', :372 ''Review'') and the permission/AskUserQuestion interaction widgets — sweep the whole extension, not just those lines.
Watch the interaction-zone widgets (D-78): keep them display-only; the i18n change is label-only, no behavioural change.
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/claude/ (single-char glyphs like ''<'' / ''+'' used as icons are exempt — note any left); all new keys present in the catalog; existing orphaned status.* keys either wired up or removed.', 'done', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:20.180', '2026-06-18 19:14:41.116', NULL, 'f231ab99f6066bf5bf45085d0c7b02eb', 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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate git + diff panel labels to the catalog (~16 strings)', 'builtin.git (~9 hardcoded) and builtin.diff (~7 hardcoded) panel/view labels bypass i18n. builtin.git has a tab.title-only catalog; builtin.diff''s catalog is created by the gaps task (T-463) — extend both.
Hotspots from the audit:
- lib/builtin/git/src/git_status_item.dart:167 ''Switch branch'', :179 ''Loading…''
- lib/builtin/git/src/git_panel_view.dart:94 ''Nothing to commit, working tree clean.''
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-18 19:19:25.268', NULL, 'c14bcdd5a816e86b78af9b5ac795b8a7', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-18 20:13:50.352', NULL, '919efbfb38b655d5157f73572aa20fad', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate remaining extension labels — menubar, output, search, deeplink, editor, files, terminal, markdown, vim (~24 strings)', 'Tail of the audit: the remaining extensions with hardcoded labels. menubar ~6 (no catalog), output ~4 (no catalog), search ~3 (no catalog), deeplink ~3 (no catalog), editor ~1, files ~1, terminal ~1, markdown ~1 (no catalog), vim ~1 (no catalog).
Several have no catalog file yet — create lib/kernel/src/i18n/catalog/builtin.<ext>_en_us.json for menubar, output, search, deeplink, markdown, vim. editor/files/terminal already have catalogs (with orphaned keys) — wire their hardcoded literals up and consume the orphans where they match.
Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place.
Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-18 20:16:03.746', NULL, '8212472a6e32ec3000b829a7abee3cb9', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: route framework-level labels in lib/widgets + lib/kernel (~10 strings)', '~9 hardcoded user-facing strings in lib/widgets/ and ~1 in lib/kernel/ live outside any extension, so they have no obvious i18n namespace. Resolve the namespace question, then migrate.
Hotspots from the audit:
- lib/widgets/src/ex_line_overlay.dart:163 '':'' , :183 ''Not an editor command'' (these are vim ex-line — may belong to builtin.vim''s namespace rather than a generic one)
- lib/widgets/src/multitab_pane.dart:358 ''+'' (likely an icon glyph — exempt if so)
Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt — note any left.
Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'done', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-18 21:21:27.304', NULL, '9817d717fa77916d07547d9674d14fcb', 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 ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'story', NULL, 'Per-repo Claude account login (not system-wide)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, '35baee58ff25f10d9ba81902bd4072b8', 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 ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'story', NULL, 'Per-repo Claude account login (not system-wide)', 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.761', NULL, '65bb4a1083e1ecfba38bfd1c3837e133', 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);