add clide ui open to drive GUI readers from the CLI (T-231)

The drive-half complement to clide status (observe): an agent can now
open a doc in a GUI reader from the CLI so it can show the user what
it's looking at. 'clide ui open tickets T-48' (decisions by id,
markdown by path) publishes a 'selection' to the kernel MessageBus that
the reader's ReaderNav picks up.

The verb (ui_command.dart) is decoupled from the kernel via a publish
callback, wired in main.dart to the post-boot-captured MessageBus, so it
stays Flutter-free under dart test. Reads CLI positionals or named args.

Also files T-232 (CLI argv args don't reach typed handlers -- the gating
drive-the-IDE bug) and T-233 (diff-panel ui.open follow-up).

Closes T-231 (under T-208).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:30:36 +02:00
co-authored by Claude Opus 4.8
parent 0d1eb0e0a9
commit 09d1a6370a
6 changed files with 209 additions and 0 deletions
@@ -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;
+43
View File
@@ -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 <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.', '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 <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).', '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 <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''.', '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 <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.', '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 <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, '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);
+3
View File
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- `clide ui open <reader> <id|path>` 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
+9
View File
@@ -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<void> 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<void> main() async {
final pql = PqlClient(workDir: workRoot, toolchain: tc);
registerPqlCommands(dispatcher, pql);
registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement));
// `clide ui open <reader> <id|path>` — 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<void> 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
+72
View File
@@ -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<String, Object?> 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<String, ({String publisher, String dataKey})> _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<IpcResponse> _open(IpcRequest req, MessagePublisher? Function() publisherSource) async {
// Accept CLI positionals (`ui open <reader> <ref>`) or named args.
final positional = (req.args['positional'] as List?)?.whereType<String>().toList() ?? const <String>[];
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 <ref>`)');
}
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});
}
+78
View File
@@ -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<String, Object?> 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<IpcResponse> open(List<String> 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);
});
}