add image-viewer card + clide image show verb
Drives an image inline into the Claude conversation log over the same bus-publish path as ui.toast/ui.open, keeping the dispatcher handler Flutter-free. The card is display-only per D-78; the verb registers a CommandSchema so it surfaces in clide capabilities for T-248 discovery. Closes T-249. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -362,3 +362,39 @@ DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+sh
|
||||
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-23', 'status', 'ready', 'done', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '121337ddae4598659f07bbe16c1ddcc3', 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-250', 'status', 'ready', 'done', NULL, '2026-06-06 07:49:08', '2026-06-06 07:49:08', '2026-06-06 07:49:08', NULL, '0765a2ccbb37deedb402d06709511928', 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-248', 'status', 'ready', 'done', NULL, '2026-06-06 07:57:16', '2026-06-06 07:57:16', '2026-06-06 07:57:16', NULL, 'b36bb78451373dae70a8b5f1f0e16ace', 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-208', 'status', 'backlog', 'ready', NULL, '2026-06-06 08:06:55', '2026-06-06 08:06:55', '2026-06-06 08:06:55', NULL, 'c6035deb44dfefa54618b264a5bb4cad', 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-208', 'status', 'ready', 'in_progress', NULL, '2026-06-06 08:12:45', '2026-06-06 08:12:45', '2026-06-06 08:12:45', NULL, '1a99ebcbec47b3377fd43373f9c01876', 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-209', 'status', 'backlog', 'in_progress', NULL, '2026-06-06 08:12:45', '2026-06-06 08:12:45', '2026-06-06 08:12:45', NULL, '9a84a354ef89306ae8b3f0835c380a4b', 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-248', 'description', '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.', '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.', NULL, '2026-06-06 08:12:53', '2026-06-06 08:12:53', '2026-06-06 08:12:53', NULL, '44685b2fe0265e9e8b110d4306de92be', 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-248', 'status', 'done', 'review', NULL, '2026-06-06 08:14:01', '2026-06-06 08:14:01', '2026-06-06 08:14:01', NULL, '1cfdde30e08e7e18f8b9eb39df3abcbc', 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-249', 'status', 'ready', 'done', NULL, '2026-06-06 08:21:40', '2026-06-06 08:21:40', '2026-06-06 08:21:40', NULL, '996d52afaf650a7d5021184a673d09a3', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -815,3 +815,76 @@ Scope:
|
||||
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.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:57:16', NULL, 'f6a976eef4c722612111256e339c8f11', 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-208', '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.', 'ready', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 08:06:55', NULL, '021035195ac2b960d7ac7f3aa35efcf9', 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-209', 'epic', 'T-208', '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.', 'in_progress', 'high', NULL, NULL, 'D-1', '2026-06-02 18:13:52', '2026-06-06 08:12:45', NULL, 'b6572a0ff80e81002cb8ce71550e8e08', 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-208', '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.', 'in_progress', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 08:12:45', NULL, 'ebd93ec3f2bcf8381bd4131117527357', 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-248', '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:12:53', NULL, 'b644ca71025fba6601ccb7ea5fa5cc18', 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-248', '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.', 'review', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:14:01', NULL, 'bca8ce0c246ffea147f7702c4cd0518d', 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-249', '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, '7280f99bd28193b894588954367eb367', 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);
|
||||
|
||||
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Image cards in the Claude conversation log: `clide image show <path>
|
||||
[--caption …]` renders an image inline (PNG/JPG/JPEG/GIF/WebP/BMP),
|
||||
clide-owned and display-only (D-78). The path is resolved workspace-relative
|
||||
and must exist; the verb registers in the dispatcher so it shows up in `clide
|
||||
capabilities`. (T-249)
|
||||
- `clide capabilities` lists the live command surface as JSON (every verb with
|
||||
its subsystem + argument schema), reflected from the dispatcher so it never
|
||||
drifts. A new `/clide` skill points Claude at it for discovery. (T-248)
|
||||
|
||||
@@ -88,9 +88,11 @@ List<RenderGroup> groupConversation(List<ConversationItem> items, FoldLevel leve
|
||||
bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> toolName) {
|
||||
if (level == FoldLevel.none) return false;
|
||||
switch (item) {
|
||||
// User prose and Claude prose are always first-class.
|
||||
// User prose, Claude prose, and driven-in image cards are always
|
||||
// first-class — an image is the point of the turn, never folded away.
|
||||
case UserMessage():
|
||||
case AssistantTextMessage():
|
||||
case ImageMessage():
|
||||
return false;
|
||||
// Thinking folds at L2+, first-class at L1.
|
||||
case AssistantThinkingMessage():
|
||||
|
||||
@@ -71,6 +71,15 @@ class ConversationController extends ChangeNotifier {
|
||||
|
||||
bool get isEmpty => _items.isEmpty;
|
||||
|
||||
/// Append a locally-produced item that did not come from the transcript
|
||||
/// stream — e.g. an image card driven by `clide image show` (T-249). It
|
||||
/// lands in arrival order and notifies listeners exactly like a streamed
|
||||
/// item, so the view renders it inline. No-op once disposed.
|
||||
void inject(ConversationItem item) {
|
||||
if (_disposed) return;
|
||||
_onItem(item);
|
||||
}
|
||||
|
||||
void _onItem(ConversationItem item) {
|
||||
// Track AssistantToolUse items by toolUseId for result-card pairing (T-168).
|
||||
if (item is AssistantToolUse) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
@@ -233,9 +234,63 @@ class _ConversationTurn extends StatelessWidget {
|
||||
),
|
||||
AssistantToolUse() => _toolUse(i),
|
||||
ToolResultMessage() => _toolResult(i),
|
||||
ImageMessage() => _image(i),
|
||||
};
|
||||
}
|
||||
|
||||
/// A driven-in image card (T-249): the image rendered inline, clide-owned
|
||||
/// (Flutter's [Image.file], no third-party viewer), display-only per D-78.
|
||||
/// Bounded so a large image scales down to the pane width and never pushes
|
||||
/// past a readable height; a missing/unreadable file degrades to a muted
|
||||
/// placeholder rather than throwing.
|
||||
Widget _image(ImageMessage m) {
|
||||
final caption = m.caption;
|
||||
return ConversationCard(
|
||||
accent: tokens.globalTextMuted,
|
||||
label: 'image',
|
||||
copyText: m.path,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: Image.file(
|
||||
File(m.path),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (caption != null && caption.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder(String path) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.image, size: 16, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toolUse(AssistantToolUse t) {
|
||||
// A resolved permission-prompted call: collapsed, green if approved / red
|
||||
// if denied — a quiet record of what was permitted (D-78).
|
||||
@@ -452,5 +507,7 @@ String _summarizeActivity(ConversationItem item) {
|
||||
return text;
|
||||
case AssistantTextMessage(:final text):
|
||||
return text;
|
||||
case ImageMessage(:final path):
|
||||
return 'image $path';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/session_storage.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
||||
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
@@ -321,6 +323,28 @@ class ClaudeExtension extends ClideExtension {
|
||||
// session outlives its pane and is shared across surfaces.
|
||||
_orchestrator = ClaudeSessionOrchestrator();
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// `clide image show <path>` (T-249): the dispatcher resolves + publishes an
|
||||
// 'image' message; we inject the matching card into the conversation the
|
||||
// user is looking at (the primary lead, else the first visible session).
|
||||
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
|
||||
}
|
||||
|
||||
/// Inject an [ImageMessage] from a published `image` bus message (T-249).
|
||||
/// Dropped silently if no live conversation is available — the CLI already
|
||||
/// reported success at publish time, and a missing pane is transient.
|
||||
void _onImageShow(Message m) {
|
||||
final path = m.data['path'] as String?;
|
||||
if (path == null || path.isEmpty) return;
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(ImageMessage(
|
||||
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
path: path,
|
||||
caption: m.data['caption'] as String?,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -141,6 +141,30 @@ final class AssistantToolUse extends ConversationItem {
|
||||
String toString() => 'AssistantToolUse(name=$name, id=$toolUseId)';
|
||||
}
|
||||
|
||||
/// A locally-injected image card (T-249). Not parsed from the transcript —
|
||||
/// driven into the conversation by `clide image show <path>` (D-6 parity) and
|
||||
/// rendered display-only per D-78. [path] is an absolute, on-disk file the
|
||||
/// driver has already resolved (workspace-relative paths are resolved before
|
||||
/// injection); [caption] is an optional one-line label.
|
||||
final class ImageMessage extends ConversationItem {
|
||||
const ImageMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.path,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
/// Absolute path to the image file on disk.
|
||||
final String path;
|
||||
|
||||
/// Optional caption shown under the image.
|
||||
final String? caption;
|
||||
|
||||
@override
|
||||
String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+14
-1
@@ -28,13 +28,14 @@ import 'package:clide/builtin/vim/vim.dart';
|
||||
import 'package:clide/builtin/tickets/tickets.dart';
|
||||
import 'package:clide/builtin/todos/todos.dart';
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'dart:io' show Directory, Platform;
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/image_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';
|
||||
@@ -215,6 +216,18 @@ Future<void> main() async {
|
||||
// (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 image show <path>` — drive an image card into the Claude
|
||||
// conversation log (T-249, drive-half of D-6). Resolves the path
|
||||
// (workspace-relative → absolute, must exist) here where workRoot is in
|
||||
// scope, then publishes an 'image' message the Claude extension injects.
|
||||
registerImageCommands(
|
||||
dispatcher,
|
||||
() => kernelMessages?.publish,
|
||||
resolve: (path) {
|
||||
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
|
||||
return file.existsSync() ? file.absolute.path : null;
|
||||
},
|
||||
);
|
||||
// `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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/// Registers `image.show` — drive an image card into the Claude conversation
|
||||
/// log from the CLI (T-249, D-6 parity).
|
||||
///
|
||||
/// clide image show docs/wireframes/hud.png
|
||||
/// clide image show /abs/path/shot.jpg --caption "before the fix"
|
||||
///
|
||||
/// The card itself is clide-owned rendering in the conversation view; this is
|
||||
/// its CLI counterpart. Like `ui.open` / `ui.toast`, the handler is decoupled
|
||||
/// from the live UI: it validates the request, resolves the path to a real
|
||||
/// on-disk file via an injected [ImagePathResolver], then publishes an `image`
|
||||
/// message on the kernel MessageBus (captured post-boot in main.dart). A
|
||||
/// consumer in the Claude extension injects the matching [ImageMessage] into
|
||||
/// the primary session's conversation. Flutter-free so it runs under
|
||||
/// `dart test`.
|
||||
library;
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
import 'ui_command.dart' show MessagePublisher;
|
||||
|
||||
/// Image formats `image.show` accepts, matched on the path's extension. Mirrors
|
||||
/// the composer's attachment sniff so what you can paste in, you can show.
|
||||
const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
|
||||
|
||||
/// Resolves a user-supplied image path (absolute or workspace-relative) to an
|
||||
/// absolute path to an existing file, or null when no such file exists.
|
||||
/// Injected so this file stays Flutter-free and unit-testable without touching
|
||||
/// the real filesystem; main.dart wires it to the workspace root + `File`.
|
||||
typedef ImagePathResolver = String? Function(String path);
|
||||
|
||||
/// The MessageBus channel `image.show` publishes on; the Claude extension
|
||||
/// subscribes to the same literal to inject the card. Kept here next to the
|
||||
/// publisher so both ends point at one name.
|
||||
const imageShowChannel = 'image';
|
||||
|
||||
void registerImageCommands(
|
||||
DaemonDispatcher d,
|
||||
MessagePublisher? Function() publisher, {
|
||||
ImagePathResolver? resolve,
|
||||
}) {
|
||||
d.register(
|
||||
'image.show',
|
||||
(req) async => _show(req, publisher, resolve),
|
||||
schema: const CommandSchema(
|
||||
positional: ['path'],
|
||||
args: {
|
||||
'path': ArgSpec(required: true, rejectLeadingDash: true),
|
||||
'caption': ArgSpec(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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> _show(
|
||||
IpcRequest req,
|
||||
MessagePublisher? Function() publisherSource,
|
||||
ImagePathResolver? resolve,
|
||||
) async {
|
||||
final path = req.args['path'] as String?;
|
||||
if (path == null || path.trim().isEmpty) {
|
||||
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)');
|
||||
}
|
||||
|
||||
final ext = _extensionOf(path);
|
||||
if (!imageShowExtensions.contains(ext)) {
|
||||
return _userErr(
|
||||
req.id,
|
||||
'unsupported image format${ext.isEmpty ? '' : ' ".$ext"'}',
|
||||
hint: 'one of: ${(imageShowExtensions.toList()..sort()).join(', ')}',
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve to a concrete file before publishing, so the CLI fails honestly on
|
||||
// a typo instead of silently showing a broken card. A null resolver (headless
|
||||
// tests) passes the path through unverified.
|
||||
String resolved = path;
|
||||
if (resolve != null) {
|
||||
final abs = resolve(path);
|
||||
if (abs == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such image: $path',
|
||||
hint: 'path is resolved relative to the workspace root',
|
||||
),
|
||||
);
|
||||
}
|
||||
resolved = abs;
|
||||
}
|
||||
|
||||
final caption = req.args['caption'] as String?;
|
||||
|
||||
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('cli', imageShowChannel, {
|
||||
'path': resolved,
|
||||
if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(),
|
||||
});
|
||||
return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'shown': true});
|
||||
}
|
||||
|
||||
/// Lower-cased extension (without the dot) of [path], or '' if none.
|
||||
String _extensionOf(String path) {
|
||||
final slash = path.lastIndexOf('/');
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot <= 0 || dot < slash || dot == path.length - 1) return '';
|
||||
return path.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
@@ -78,5 +78,13 @@ void main() {
|
||||
test('empty input yields no groups', () {
|
||||
expect(groupConversation(const [], FoldLevel.tools), isEmpty);
|
||||
});
|
||||
|
||||
test('an image card stays first-class even at L3 (everything)', () {
|
||||
final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png');
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything);
|
||||
// The tool + result fold; the image is sticky and seals the cluster.
|
||||
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem']);
|
||||
expect((groups[1] as StickyItem).item, isA<ImageMessage>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Image, FileImage;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -29,6 +30,7 @@ AssistantToolUse _tool(String name, Map<String, dynamic> input) =>
|
||||
AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input);
|
||||
ToolResultMessage _result(String content, {bool isError = false}) =>
|
||||
ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError);
|
||||
ImageMessage _image(String path, {String? caption}) => ImageMessage(uuid: 'i', timestamp: _t, isSidechain: false, path: path, caption: caption);
|
||||
|
||||
class _MockClipboard {
|
||||
Map<String, dynamic> _data = {'text': null};
|
||||
@@ -187,6 +189,23 @@ void main() {
|
||||
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an image card renders with the "image" label, the file, and its caption (T-249)', (tester) async {
|
||||
await pumpWith(tester, [_image('/no/such/file.png', caption: 'before the fix')]);
|
||||
expect(find.text('image'), findsOneWidget);
|
||||
expect(find.text('before the fix'), findsOneWidget);
|
||||
// The image is wired to the resolved file path (display-only, D-78).
|
||||
final img = tester.widget<Image>(find.byType(Image));
|
||||
expect((img.image as FileImage).file.path, '/no/such/file.png');
|
||||
});
|
||||
|
||||
testWidgets('inject() drives a new image card into a live view (T-249)', (tester) async {
|
||||
final c = await pumpWith(tester, [_user('hi')]);
|
||||
expect(find.text('image'), findsNothing);
|
||||
c.inject(_image('/no/such/shot.png'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('image'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/// Tests for `image.show` — the CLI that drives an image card into the Claude
|
||||
/// conversation log (T-249, D-6 parity). Verifies format validation, path
|
||||
/// resolution, the published MessageBus 'image' payload, and honest failure
|
||||
/// when the file is missing or there is no live UI.
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/image_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late List<({String publisher, String channel, Map<String, Object?> data})> published;
|
||||
late DaemonDispatcher d;
|
||||
|
||||
// [found] is the set of paths the fake resolver treats as existing on disk;
|
||||
// it echoes them back prefixed with /abs to stand in for an absolute path.
|
||||
void wire({bool liveUi = true, Set<String> found = const {'docs/diagram.png'}, bool withResolver = true}) {
|
||||
published = [];
|
||||
d = DaemonDispatcher();
|
||||
registerImageCommands(
|
||||
d,
|
||||
() {
|
||||
if (!liveUi) return null;
|
||||
return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data));
|
||||
},
|
||||
resolve: withResolver ? (path) => found.contains(path) ? '/abs/$path' : null : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> show(List<String> positional, {Map<String, Object?>? flags}) => d.dispatch(
|
||||
IpcRequest(id: '1', cmd: 'image.show', args: {'positional': positional, if (flags != null) 'flags': flags}),
|
||||
);
|
||||
|
||||
test('resolves a workspace-relative path and publishes an image message', () async {
|
||||
wire();
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['shown'], isTrue);
|
||||
expect(r.data['path'], '/abs/docs/diagram.png');
|
||||
expect(published.single.publisher, 'cli');
|
||||
expect(published.single.channel, 'image');
|
||||
expect(published.single.data, {'path': '/abs/docs/diagram.png'});
|
||||
});
|
||||
|
||||
test('--caption rides along in the payload', () async {
|
||||
wire();
|
||||
final r = await show(['docs/diagram.png'], flags: {'caption': 'before the fix'});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data, {'path': '/abs/docs/diagram.png', 'caption': 'before the fix'});
|
||||
});
|
||||
|
||||
test('accepts the documented formats case-insensitively', () async {
|
||||
for (final name in ['a.PNG', 'b.jpg', 'c.jpeg', 'd.gif', 'e.webp', 'f.bmp']) {
|
||||
wire(found: {name});
|
||||
final r = await show([name]);
|
||||
expect(r.ok, isTrue, reason: '$name: ${r.error?.message}');
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported format → userError, nothing published', () async {
|
||||
wire(found: {'notes.txt'});
|
||||
final r = await show(['notes.txt']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('missing path → userError', () async {
|
||||
wire();
|
||||
final r = await show([]);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('a file that does not resolve → notFound, nothing published', () async {
|
||||
wire(found: const {});
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.notFound);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('no live UI (null publisher) → toolError, not a hang', () async {
|
||||
wire(liveUi: false);
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
|
||||
test('a leading-dash path is rejected by the schema (argv-injection guard)', () async {
|
||||
wire();
|
||||
final r = await show(['-rf.png']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('null resolver passes the path through unverified (headless)', () async {
|
||||
wire(withResolver: false);
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data, {'path': 'docs/diagram.png'});
|
||||
});
|
||||
|
||||
test('image.show appears in the capabilities discovery surface (T-248)', () async {
|
||||
wire();
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'capabilities', args: const {}));
|
||||
expect(r.ok, isTrue);
|
||||
final commands = r.data['commands'] as Map<String, Object?>;
|
||||
expect(commands.containsKey('image.show'), isTrue);
|
||||
final spec = commands['image.show'] as Map<String, Object?>;
|
||||
expect(spec['subsystem'], 'image');
|
||||
expect(spec['verb'], 'show');
|
||||
expect(spec['positional'], ['path']);
|
||||
final args = spec['args'] as Map<String, Object?>;
|
||||
expect((args['path'] as Map)['required'], true);
|
||||
expect(args.containsKey('caption'), isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user