diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 756f8e9b..88df6907 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -100,3 +100,7 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-221', 'status', 'in_progress', 'done', NULL, '2026-06-03 11:50:02', '2026-06-03 11:50:02', '2026-06-03 11:50:02', NULL, '141f9d7024902bf3dc1c50337c6aa543', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-218', 'status', 'backlog', 'done', NULL, '2026-06-03 11:50:08', '2026-06-03 11:50:08', '2026-06-03 11:50:08', NULL, '9afcf2b444b85f4c7c5e6ba14d73a04d', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'status', 'done', 'done', NULL, '2026-06-03 11:50:23', '2026-06-03 11:50:23', '2026-06-03 11:50:23', NULL, '0abbcea442a884b74a28ff8146dad9d6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-231', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 13:21:05', '2026-06-03 13:21:05', '2026-06-03 13:21:05', NULL, '881bfdf235311aad2e6aa1ae3e8140d1', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'status', 'backlog', 'ready', NULL, '2026-06-03 13:23:43', '2026-06-03 13:23:43', '2026-06-03 13:23:43', NULL, '8c28d5cfbe764a82c8cc592e77513b9f', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-82', 'status', 'backlog', 'ready', NULL, '2026-06-03 13:24:18', '2026-06-03 13:24:18', '2026-06-03 13:24:18', NULL, '7fed4ac1978468aef2f95e5ebdeaabc7', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-231', 'status', 'in_progress', 'done', NULL, '2026-06-03 13:30:05', '2026-06-03 13:30:05', '2026-06-03 13:30:05', NULL, '00d811eac9a20406a824de9134055fda', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 3f019f8b..5a061156 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -239,3 +239,46 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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, '38722e333acfbd8f5b8491624c587c69', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-231', 'task', 'T-208', '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 ) 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.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:17:32', '2026-06-03 13:17:32', NULL, '9a36072c19b514a56efe7ee7b5970457', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'story', 'T-132', '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 / 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).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 13:23:43', NULL, '21cff726143d1c2b3102ee1ef5b6bfdb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-82', '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.', 'ready', 'low', NULL, NULL, NULL, '2026-05-06 08:30:15', '2026-06-03 13:24:18', NULL, 'b8cbb4530197f04a1d5277a9cfee5dbc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-232', 'task', 'T-208', '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 '' 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''.', 'backlog', 'high', NULL, NULL, 'D-6', '2026-06-03 13:29:49', '2026-06-03 13:29:49', NULL, '9b89ce5c134ecd097db7e5bd19f3ff75', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-233', 'task', 'T-208', '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 '' 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.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-03 13:29:59', '2026-06-03 13:29:59', NULL, 'bf44a02d9e8daba9f5d6a6f486c0c881', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-231', 'task', 'T-208', '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 ) 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, '78cb72d5be8881a31a0cad37ec138d21', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index dfda0490..0c4d99df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- `clide ui open ` opens a doc in a GUI reader from the CLI — + `tickets`/`decisions` by id, `markdown` by path — so an agent can surface what + it's looking at on your screen. The drive-half complement to `clide status`. (T-231) - Cycle Claude's permission mode from the primary pane: Ctrl/Cmd+M while the composer is focused, a clickable mode badge in the status line, or the "Claude: Cycle permission mode" palette command — steps default → accept-edits diff --git a/lib/main.dart b/lib/main.dart index a76ebcdc..ab5c0960 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,6 +37,7 @@ import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/status_command.dart'; +import 'package:clide/src/daemon/ui_command.dart'; import 'package:clide/src/daemon/panel_commands.dart'; import 'package:clide/src/daemon/panel_resizer_kernel.dart'; import 'package:clide/src/daemon/pql_commands.dart'; @@ -92,6 +93,9 @@ Future main() async { // Captured after boot so `clide status` can report the read-only reader's // viewed doc (D-81), which isn't an editor buffer (T-221). ReaderNavRegistry? kernelReaderNav; + // The kernel MessageBus, captured post-boot so `ui.open` can drive the GUI + // readers (publish a 'selection') from the CLI — the drive-half of D-6 (T-231). + MessageBus? kernelMessages; // IPC socket server (T-99 / T-124, per D-70/71/72). One server per // workspace; restarted when the active project switches because the // socket path is workspace-derived. The local DaemonClient connects @@ -207,6 +211,10 @@ Future main() async { final pql = PqlClient(workDir: workRoot, toolchain: tc); registerPqlCommands(dispatcher, pql); registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement)); + // `clide ui open ` — drive the GUI readers from the CLI + // (T-231, drive-half of D-6). Publishes a 'selection' to the kernel + // MessageBus, captured post-boot; null in headless contexts. + registerUiCommands(dispatcher, () => kernelMessages?.publish); // `clide status` — one-shot orientation snapshot (T-221): active pane, // focused file + selection, git summary, layout. Assembled here where the // live kernel + subsystem state is in scope; the reader's viewed doc is @@ -317,6 +325,7 @@ Future main() async { // creates it before the daemonClientFactory runs, but the status closure // only reads it at request time (post-boot), so capturing it here is safe. kernelReaderNav = services.readerNav; + kernelMessages = services.messages; // Register every built-in. Tier 0 activates only the four that do // real work; the rest compile in as stubs so the extensions-ui can diff --git a/lib/src/daemon/ui_command.dart b/lib/src/daemon/ui_command.dart new file mode 100644 index 00000000..9ed55c1d --- /dev/null +++ b/lib/src/daemon/ui_command.dart @@ -0,0 +1,72 @@ +/// Registers `ui.open` — the drive-half of D-6 parity (T-231). +/// +/// Epic C (T-218) gave the CLI the *observe* half: `clide status` / `pane +/// list` read live UI state. This is the complement — the agent asks the +/// GUI to open a doc in one of its readers, so it can say "look at this +/// with me." Opening a reader doc is otherwise a UI-only action (a click +/// publishes a `selection` to the kernel MessageBus); this verb publishes +/// the same message from the CLI. +/// +/// clide ui open tickets T-48 +/// clide ui open decisions D-17 +/// clide ui open markdown docs/initial-plan.md +/// +/// The handler is decoupled from the kernel: it takes a [MessagePublisher] +/// callback (wired to the MessageBus in main.dart), so this file stays +/// Flutter-free and runs under `dart test`. +library; + +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import 'dispatcher.dart'; + +/// Publishes a message to the kernel MessageBus. `void Function(publisher, +/// channel, data)` — the tear-off of `MessageBus.publish`. +typedef MessagePublisher = void Function(String publisher, String channel, Map data); + +/// The readers `ui.open` can target → (bus publisher id, payload key the +/// reader reads its entry from). Matches each reader's `ReaderNav` dataKey +/// (tickets/decisions key on `id`; markdown on `path`). +const Map _readers = { + 'tickets': (publisher: 'builtin.tickets', dataKey: 'id'), + 'decisions': (publisher: 'builtin.decisions', dataKey: 'id'), + 'markdown': (publisher: 'builtin.markdown', dataKey: 'path'), +}; + +void registerUiCommands(DaemonDispatcher d, MessagePublisher? Function() publisher) { + d.register('ui.open', (req) async => _open(req, publisher)); +} + +IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err( + id: id, + error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint), + ); + +Future _open(IpcRequest req, MessagePublisher? Function() publisherSource) async { + // Accept CLI positionals (`ui open `) or named args. + final positional = (req.args['positional'] as List?)?.whereType().toList() ?? const []; + final reader = (req.args['reader'] as String?) ?? (positional.isNotEmpty ? positional[0] : null); + final ref = (req.args['ref'] as String?) ?? (positional.length > 1 ? positional[1] : null); + + if (reader == null) { + return _userErr(req.id, 'reader is required', hint: 'one of: ${_readers.keys.join(', ')}'); + } + final target = _readers[reader]; + if (target == null) { + return _userErr(req.id, 'unknown reader: $reader', hint: 'one of: ${_readers.keys.join(', ')}'); + } + if (ref == null || ref.isEmpty) { + return _userErr(req.id, 'a doc id/path is required (e.g. `ui open $reader `)'); + } + + final publish = publisherSource(); + if (publish == null) { + // No live UI bus — headless / CLI-only context. Honest failure, not a hang. + return IpcResponse.err( + id: req.id, + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'), + ); + } + publish(target.publisher, 'selection', {target.dataKey: ref}); + return IpcResponse.ok(id: req.id, data: {'reader': reader, 'ref': ref, 'opened': true}); +} diff --git a/test/daemon/ui_command_test.dart b/test/daemon/ui_command_test.dart new file mode 100644 index 00000000..151f9c4a --- /dev/null +++ b/test/daemon/ui_command_test.dart @@ -0,0 +1,78 @@ +/// Tests for `ui.open` — the drive-half of D-6 parity (T-231). Verifies it +/// publishes the right MessageBus 'selection' per reader, validates input, +/// and fails honestly when there is no live UI. +library; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/ui_command.dart'; +import 'package:test/test.dart'; + +void main() { + // Records what would be published to the kernel MessageBus. + late List<({String publisher, String channel, Map data})> published; + late DaemonDispatcher d; + + void wire({bool liveUi = true}) { + published = []; + d = DaemonDispatcher(); + registerUiCommands(d, () { + if (!liveUi) return null; + return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data)); + }); + } + + Future open(List positional) => d.dispatch( + IpcRequest(id: '1', cmd: 'ui.open', args: {'positional': positional}), + ); + + test('ui open tickets T-48 publishes a tickets selection', () async { + wire(); + final r = await open(['tickets', 'T-48']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['opened'], isTrue); + expect(published, hasLength(1)); + expect(published.single.publisher, 'builtin.tickets'); + expect(published.single.channel, 'selection'); + expect(published.single.data, {'id': 'T-48'}); + }); + + test('decisions keys on id; markdown keys on path', () async { + wire(); + await open(['decisions', 'D-17']); + await open(['markdown', 'docs/initial-plan.md']); + expect(published[0].publisher, 'builtin.decisions'); + expect(published[0].data, {'id': 'D-17'}); + expect(published[1].publisher, 'builtin.markdown'); + expect(published[1].data, {'path': 'docs/initial-plan.md'}); + }); + + test('named args work too (reader/ref)', () async { + wire(); + final r = await d.dispatch(IpcRequest(id: '1', cmd: 'ui.open', args: {'reader': 'tickets', 'ref': 'T-7'})); + expect(r.ok, isTrue); + expect(published.single.data, {'id': 'T-7'}); + }); + + test('unknown reader → userError, nothing published', () async { + wire(); + final r = await open(['canvas', 'foo']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('missing ref → userError', () async { + wire(); + final r = await open(['tickets']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('no live UI (null publisher) → toolError, not a hang', () async { + wire(liveUi: false); + final r = await open(['tickets', 'T-48']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.toolError); + }); +}