From f0fb5a513405b39040f29c86b87189bc133b432e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 29 Jun 2026 12:58:30 +0200 Subject: [PATCH] feat(image): --file metadata payload for annotated image cards (T-316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clide image show --file meta.json` reads a {path,label,description,caption} payload so an image can carry a title and a longer description, not just a one-line caption. ImageMessage + the image card render the richer metadata; the bare `image show [--caption]` form is unchanged. Honest userError on a malformed/missing payload. Text annotation only (option a) — visual marker overlays stay a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 21 ++++++ .pql/changelog/tickets/2026-06.sql | 52 +++++++++++++++ CHANGELOG.md | 4 ++ lib/builtin/claude/src/conversation_view.dart | 9 +++ lib/builtin/claude/src/extension.dart | 2 + lib/builtin/claude/src/transcript_reader.dart | 14 +++- lib/main.dart | 8 +++ lib/src/daemon/image_commands.dart | 65 ++++++++++++++++--- .../claude/conversation_view_test.dart | 10 ++- test/daemon/image_commands_test.dart | 39 +++++++++-- 10 files changed, 207 insertions(+), 17 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index f13a5571..7b07368e 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -8887,3 +8887,24 @@ RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + te DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools. + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.640', '2026-06-29 07:32:43.640', NULL, '0dfb6eeb1bcde2f386379f029fa4d0f6', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'status', 'in_progress', 'done', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.682', '2026-06-29 07:32:43.682', NULL, '45aeefee25353af62e4aa9f7b1f3265c', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'description', 'A conversation-pane drawing card (peer of the SVG card T-320) that renders a D2 diagram. SEPARATE card type from SVG, but the ONLY difference is a compile step in front: D2 source → compile to SVG → render through the SAME SVG widget (T-320). Blocked on T-320 for that reason. + +CLI/parity (D-6): driven via the drawing-card dispatch (T-318) — ''clide draw --file pipeline.d2'' (type inferred from .d2), or template:"d2" with inline source in the drawing-card JSON. Honest userError on a d2 compile failure (bad source), mirroring image/svg validation. + +UX: the rendered diagram LEADS; the d2 source folds into a collapsed ''view d2 source'' disclosure on the card (ClideCollapserCard pattern, T-305) so the code doesn''t dominate. Display-only per D-78. + +Open: D2 compiler dependency — d2 is Go (there is a d2-diagram skill). Decide whether to shell out to a d2 binary (supporter-tool pattern, peer of pql) or vendor it; flag before building. Wireframe: docs/design/wireframes/conversation/d2-render-card.png. + +PATH / tool-resolution dependency (2026-06-28): the d2 compile step shells out to the ''d2'' binary, so it relies on clide resolving d2 on PATH. clide already derives a login-shell PATH for every spawned tool — T-439 (consolidated the Linux PATH fix T-347), both DONE — so this is very likely already covered. ACCEPTANCE: verify clide resolves ''d2'' via that login-shell PATH on a Homebrew/linuxbrew install (d2 lives in /home/linuxbrew/.linuxbrew/bin). WATCH: a ''$SHELL -lc'' login probe can miss brew if ''brew shellenv'' is only in ~/.bashrc (login shells source .bash_profile/.profile, not .bashrc); if so, that''s a real follow-up on T-439''s probe surfaced from here. Honest userError if d2 is unresolved, hinting how to install/locate it. (The ''command -v d2'' miss seen during design was Claude Code''s own lean sandbox PATH, not clide.)', 'A conversation-pane drawing card (peer of the SVG card T-320) that renders a D2 diagram. SEPARATE card type from SVG, but the ONLY difference is a compile step in front: D2 source → compile to SVG → render through the SAME SVG widget (T-320). Blocked on T-320 for that reason. + +CLI/parity (D-6): driven via the drawing-card dispatch (T-318) — ''clide draw --file pipeline.d2'' (type inferred from .d2), or template:"d2" with inline source in the drawing-card JSON. Honest userError on a d2 compile failure (bad source), mirroring image/svg validation. + +UX: the rendered diagram LEADS; the d2 source folds into a collapsed ''view d2 source'' disclosure on the card (ClideCollapserCard pattern, T-305) so the code doesn''t dominate. Display-only per D-78. + +Open: D2 compiler dependency — d2 is Go (there is a d2-diagram skill). Decide whether to shell out to a d2 binary (supporter-tool pattern, peer of pql) or vendor it; flag before building. Wireframe: docs/design/wireframes/conversation/d2-render-card.png. + +PATH / tool-resolution dependency (2026-06-28): the d2 compile step shells out to the ''d2'' binary, so it relies on clide resolving d2 on PATH. clide already derives a login-shell PATH for every spawned tool — T-439 (consolidated the Linux PATH fix T-347), both DONE — so this is very likely already covered. ACCEPTANCE: verify clide resolves ''d2'' via that login-shell PATH on a Homebrew/linuxbrew install (d2 lives in /home/linuxbrew/.linuxbrew/bin). WATCH: a ''$SHELL -lc'' login probe can miss brew if ''brew shellenv'' is only in ~/.bashrc (login shells source .bash_profile/.profile, not .bashrc); if so, that''s a real follow-up on T-439''s probe surfaced from here. Honest userError if d2 is unresolved, hinting how to install/locate it. (The ''command -v d2'' miss seen during design was Claude Code''s own lean sandbox PATH, not clide.) + +DONE (2026-06-29): d2 diagram template landed + fast suite green. Decision RESOLVED: shell out to the d2 binary (supporter-tool pattern, D-3/D-5) — not vendored. d2TemplateHandler compiles doc.source -> SVG via ''d2 - -'', resolved through the D-104 path layer (T-495); painted by the T-320 SVG renderer. CLI infers type from .d2 (and .svg renders directly). Template handlers now return DrawResult so an unresolved d2 or a compile failure surface as honest userError with an install hint. The d2 source folds into a collapsed ''view source'' disclosure (D-78). ACCEPTANCE VERIFIED LIVE: resolver found /home/linuxbrew/.linuxbrew/bin/d2 and compiled a -> b to a 13.5KB SVG end-to-end (the brew-on-linux case). Commits 64d77ec5 (core) + 5f8fa6af (disclosure).', NULL, '2026-06-29 10:23:55', '2026-06-29 10:23:55.745', '2026-06-29 10:23:55.745', NULL, '1ab9a75ee23a5332edc9e3459a92b6cd', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'status', 'backlog', 'done', NULL, '2026-06-29 10:23:55', '2026-06-29 10:23:55.781', '2026-06-29 10:23:55.781', NULL, 'ff09222d1e3a77f0a082caf302aedf07', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 10:41:42', '2026-06-29 10:41:42.262', '2026-06-29 10:41:42.262', NULL, '6ecda6d328b1cc8ade1d80e5f2de08f1', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 79b321a7..2d367fba 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10678,3 +10678,55 @@ Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. S RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools. DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools. + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'done', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.682', NULL, '70724bd4ac9c1da06f8d1c252492a4b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'D2 diagram card — compile d2 source to SVG, reuse the SVG render widget', 'A conversation-pane drawing card (peer of the SVG card T-320) that renders a D2 diagram. SEPARATE card type from SVG, but the ONLY difference is a compile step in front: D2 source → compile to SVG → render through the SAME SVG widget (T-320). Blocked on T-320 for that reason. + +CLI/parity (D-6): driven via the drawing-card dispatch (T-318) — ''clide draw --file pipeline.d2'' (type inferred from .d2), or template:"d2" with inline source in the drawing-card JSON. Honest userError on a d2 compile failure (bad source), mirroring image/svg validation. + +UX: the rendered diagram LEADS; the d2 source folds into a collapsed ''view d2 source'' disclosure on the card (ClideCollapserCard pattern, T-305) so the code doesn''t dominate. Display-only per D-78. + +Open: D2 compiler dependency — d2 is Go (there is a d2-diagram skill). Decide whether to shell out to a d2 binary (supporter-tool pattern, peer of pql) or vendor it; flag before building. Wireframe: docs/design/wireframes/conversation/d2-render-card.png. + +PATH / tool-resolution dependency (2026-06-28): the d2 compile step shells out to the ''d2'' binary, so it relies on clide resolving d2 on PATH. clide already derives a login-shell PATH for every spawned tool — T-439 (consolidated the Linux PATH fix T-347), both DONE — so this is very likely already covered. ACCEPTANCE: verify clide resolves ''d2'' via that login-shell PATH on a Homebrew/linuxbrew install (d2 lives in /home/linuxbrew/.linuxbrew/bin). WATCH: a ''$SHELL -lc'' login probe can miss brew if ''brew shellenv'' is only in ~/.bashrc (login shells source .bash_profile/.profile, not .bashrc); if so, that''s a real follow-up on T-439''s probe surfaced from here. Honest userError if d2 is unresolved, hinting how to install/locate it. (The ''command -v d2'' miss seen during design was Claude Code''s own lean sandbox PATH, not clide.) + +DONE (2026-06-29): d2 diagram template landed + fast suite green. Decision RESOLVED: shell out to the d2 binary (supporter-tool pattern, D-3/D-5) — not vendored. d2TemplateHandler compiles doc.source -> SVG via ''d2 - -'', resolved through the D-104 path layer (T-495); painted by the T-320 SVG renderer. CLI infers type from .d2 (and .svg renders directly). Template handlers now return DrawResult so an unresolved d2 or a compile failure surface as honest userError with an install hint. The d2 source folds into a collapsed ''view source'' disclosure (D-78). ACCEPTANCE VERIFIED LIVE: resolver found /home/linuxbrew/.linuxbrew/bin/d2 and compiled a -> b to a 13.5KB SVG end-to-end (the brew-on-linux case). Commits 64d77ec5 (core) + 5f8fa6af (disclosure).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 14:01:13.733', '2026-06-29 10:23:55.745', NULL, 'ed7a17c9fcf8d9759f35ec444383e2f6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'D2 diagram card — compile d2 source to SVG, reuse the SVG render widget', 'A conversation-pane drawing card (peer of the SVG card T-320) that renders a D2 diagram. SEPARATE card type from SVG, but the ONLY difference is a compile step in front: D2 source → compile to SVG → render through the SAME SVG widget (T-320). Blocked on T-320 for that reason. + +CLI/parity (D-6): driven via the drawing-card dispatch (T-318) — ''clide draw --file pipeline.d2'' (type inferred from .d2), or template:"d2" with inline source in the drawing-card JSON. Honest userError on a d2 compile failure (bad source), mirroring image/svg validation. + +UX: the rendered diagram LEADS; the d2 source folds into a collapsed ''view d2 source'' disclosure on the card (ClideCollapserCard pattern, T-305) so the code doesn''t dominate. Display-only per D-78. + +Open: D2 compiler dependency — d2 is Go (there is a d2-diagram skill). Decide whether to shell out to a d2 binary (supporter-tool pattern, peer of pql) or vendor it; flag before building. Wireframe: docs/design/wireframes/conversation/d2-render-card.png. + +PATH / tool-resolution dependency (2026-06-28): the d2 compile step shells out to the ''d2'' binary, so it relies on clide resolving d2 on PATH. clide already derives a login-shell PATH for every spawned tool — T-439 (consolidated the Linux PATH fix T-347), both DONE — so this is very likely already covered. ACCEPTANCE: verify clide resolves ''d2'' via that login-shell PATH on a Homebrew/linuxbrew install (d2 lives in /home/linuxbrew/.linuxbrew/bin). WATCH: a ''$SHELL -lc'' login probe can miss brew if ''brew shellenv'' is only in ~/.bashrc (login shells source .bash_profile/.profile, not .bashrc); if so, that''s a real follow-up on T-439''s probe surfaced from here. Honest userError if d2 is unresolved, hinting how to install/locate it. (The ''command -v d2'' miss seen during design was Claude Code''s own lean sandbox PATH, not clide.) + +DONE (2026-06-29): d2 diagram template landed + fast suite green. Decision RESOLVED: shell out to the d2 binary (supporter-tool pattern, D-3/D-5) — not vendored. d2TemplateHandler compiles doc.source -> SVG via ''d2 - -'', resolved through the D-104 path layer (T-495); painted by the T-320 SVG renderer. CLI infers type from .d2 (and .svg renders directly). Template handlers now return DrawResult so an unresolved d2 or a compile failure surface as honest userError with an install hint. The d2 source folds into a collapsed ''view source'' disclosure (D-78). ACCEPTANCE VERIFIED LIVE: resolver found /home/linuxbrew/.linuxbrew/bin/d2 and compiled a -> b to a 13.5KB SVG end-to-end (the brew-on-linux case). Commits 64d77ec5 (core) + 5f8fa6af (disclosure).', 'done', 'medium', NULL, NULL, NULL, '2026-06-28 14:01:13.733', '2026-06-29 10:23:55.780', NULL, 'bd4bf02fcf625ba819d85d2c3cd97f76', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption. + +## Why + +Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces. + +## Deliverable + +Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file ` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.: + + { "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" } + +Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show --caption "…"` form working unchanged (back-compat); the JSON payload is additive. + +## Open decision — text vs visual annotation + +''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers. + +## Dependencies + +- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready. +- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path. + +## Acceptance + +- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption. +- ImageMessage + the image card render the added metadata. +- The existing `image show [--caption]` form is unchanged. +- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation. +- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-29 10:41:42.262', NULL, 'b2f5d954720a3a837142c31aaac94257', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, 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 29892474..c6b9851b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Annotated image cards.** `clide image show --file meta.json` attaches a + title/label and a longer description to an image card (alongside the existing + one-line caption); the bare `image show --caption` form is unchanged. + Visual marker overlays remain a follow-up. (T-316) - **D2 diagram cards.** `clide draw --file diagram.d2` compiles a d2 diagram to SVG (via the `d2` binary) and renders it in the conversation; `template:"d2"` with inline source works too, and a `.svg` file renders directly. Honest error diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 23f79bcd..5690efb1 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -749,6 +749,11 @@ class _ConversationTurn extends StatelessWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Annotation title above the image (T-316), when a --file payload set it. + if (m.label != null && m.label!.isNotEmpty) ...[ + ClideText(m.label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground), + const SizedBox(height: 4), + ], // The card stays display-only (D-78); the click is a navigation // gesture that opens the full-screen lightbox (T-252), not an inline // control. @@ -770,6 +775,10 @@ class _ConversationTurn extends StatelessWidget { ), ), ), + if (m.description != null && m.description!.isNotEmpty) ...[ + const SizedBox(height: 6), + ClideText(m.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted), + ], if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)], ], ), diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 68ff99de..ea0b3900 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -641,6 +641,8 @@ class ClaudeExtension extends ClideExtension { isSidechain: false, path: path, caption: m.data['caption'] as String?, + label: m.data['label'] as String?, + description: m.data['description'] as String?, ), ); } diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index b9e92aa1..ac7b8b5e 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -178,7 +178,15 @@ final class AssistantToolUse extends ConversationItem { /// 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}); + const ImageMessage({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.path, + this.caption, + this.label, + this.description, + }); /// Absolute path to the image file on disk. final String path; @@ -186,6 +194,10 @@ final class ImageMessage extends ConversationItem { /// Optional caption shown under the image. final String? caption; + /// Optional richer annotations from a `--file` metadata payload (T-316): a + /// title/label above the image and a longer description beneath it. + final String? label, description; + @override String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})'; } diff --git a/lib/main.dart b/lib/main.dart index 2a50ea23..20adc0b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -368,6 +368,14 @@ Future main() async { final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); return file.existsSync() ? file.absolute.path : null; }, + readFile: (path) async { + final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); + try { + return file.existsSync() ? await file.readAsString() : null; + } catch (_) { + return null; + } + }, ); // `clide draw --file ` — drive a drawing card into the Claude // conversation (T-318, drive-half of D-6). Reads the JSON doc relative to diff --git a/lib/src/daemon/image_commands.dart b/lib/src/daemon/image_commands.dart index 40de1195..a76f53f2 100644 --- a/lib/src/daemon/image_commands.dart +++ b/lib/src/daemon/image_commands.dart @@ -14,6 +14,8 @@ /// `dart test`. library; +import 'dart:convert'; + import '../ipc/command_schema.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; @@ -30,20 +32,26 @@ const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}; /// the real filesystem; main.dart wires it to the workspace root + `File`. typedef ImagePathResolver = String? Function(String path); +/// Reads a metadata JSON file's contents, or null if unreadable. Injected so +/// this file stays Flutter-free and unit-testable (T-316). +typedef ImageFileReader = Future 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}) { +void registerImageCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {ImagePathResolver? resolve, ImageFileReader? readFile}) { d.register( 'image.show', - (req) async => _show(req, publisher, resolve), + (req) async => _show(req, publisher, resolve, readFile), schema: const CommandSchema( positional: ['path'], args: { - 'path': ArgSpec(required: true, rejectLeadingDash: true), + // Not required — the path may instead come from a --file payload (T-316). + 'path': ArgSpec(rejectLeadingDash: true), 'caption': ArgSpec(), + 'file': ArgSpec(rejectLeadingDash: true), 'fullscreen': ArgSpec(type: ArgType.boolean), }, ), @@ -55,10 +63,43 @@ IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.e error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint), ); -Future _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve) async { - final path = req.args['path'] as String?; +Future _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve, ImageFileReader? readFile) async { + String? path = req.args['path'] as String?; + String? label, description; + String? caption = req.args['caption'] as String?; + + // --file : an annotation payload {path,label,description,caption} + // (T-316). Additive — the bare `image show [--caption]` form is + // unchanged; label/description are the new richer metadata. + final file = req.args['file'] as String?; + if (file != null && file.trim().isNotEmpty) { + final raw = readFile == null ? null : await readFile(file); + if (raw == null) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: 'no such file: $file', + hint: 'path is resolved relative to the workspace root', + ), + ); + } + Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + } + if (decoded is! Map) return _userErr(req.id, 'image metadata must be a JSON object'); + path = _str(decoded['path']) ?? path; + label = _str(decoded['label']); + description = _str(decoded['description']); + caption = _str(decoded['caption']) ?? caption; + } + if (path == null || path.trim().isEmpty) { - return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)'); + return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png` or `image show --file meta.json`)'); } final ext = _extensionOf(path); @@ -86,8 +127,6 @@ Future _show(IpcRequest req, MessagePublisher? Function() publisher 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. @@ -100,11 +139,19 @@ Future _show(IpcRequest req, MessagePublisher? Function() publisher publish('cli', imageShowChannel, { 'path': resolved, if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(), + 'label': ?label, + 'description': ?description, if (fullscreen) 'fullscreen': true, }); - return IpcResponse.ok(id: req.id, data: {'path': resolved, 'caption': ?caption, 'fullscreen': fullscreen, 'shown': true}); + return IpcResponse.ok( + id: req.id, + data: {'path': resolved, 'caption': ?caption, 'label': ?label, 'description': ?description, 'fullscreen': fullscreen, 'shown': true}, + ); } +/// Trimmed non-empty string, or null — for tolerant JSON field reads. +String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null; + /// Lower-cased extension (without the dot) of [path], or '' if none. String _extensionOf(String path) { final slash = path.lastIndexOf('/'); diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 84684268..e20d324d 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -35,7 +35,8 @@ AssistantToolUse _tool(String name, Map 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); +ImageMessage _image(String path, {String? caption, String? label, String? description}) => + ImageMessage(uuid: 'i', timestamp: _t, isSidechain: false, path: path, caption: caption, label: label, description: description); class _MockClipboard { Map _data = {'text': null}; @@ -472,6 +473,13 @@ void main() { expect((img.image as FileImage).file.path, '/no/such/file.png'); }); + testWidgets('an image card renders its --file label and description (T-316)', (tester) async { + await pumpWith(tester, [_image('/no/such/shot.png', label: 'HUD v3', description: 'note the cramped status row', caption: 'before')]); + expect(find.text('HUD v3'), findsOneWidget); + expect(find.text('note the cramped status row'), findsOneWidget); + expect(find.text('before'), findsOneWidget); + }); + 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); diff --git a/test/daemon/image_commands_test.dart b/test/daemon/image_commands_test.dart index 5fe5ba72..d74dbca9 100644 --- a/test/daemon/image_commands_test.dart +++ b/test/daemon/image_commands_test.dart @@ -14,13 +14,18 @@ void main() { // [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 found = const {'docs/diagram.png'}, bool withResolver = true}) { + void wire({bool liveUi = true, Set found = const {'docs/diagram.png'}, bool withResolver = true, Map files = const {}}) { 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); + 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, + readFile: (path) async => files[path], + ); } Future show(List positional, {Map? flags}) => @@ -44,6 +49,26 @@ void main() { expect(published.single.data, {'path': '/abs/docs/diagram.png', 'caption': 'before the fix'}); }); + test('--file metadata payload carries label + description (T-316)', () async { + wire(found: {'docs/shot.png'}, files: {'meta.json': '{"path":"docs/shot.png","label":"HUD v3","description":"cramped status row","caption":"before"}'}); + final r = await show([], flags: {'file': 'meta.json'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data, {'path': '/abs/docs/shot.png', 'caption': 'before', 'label': 'HUD v3', 'description': 'cramped status row'}); + }); + + test('a malformed --file payload is an honest userError, nothing published', () async { + wire(files: {'meta.json': 'not json'}); + final r = await show([], flags: {'file': 'meta.json'}); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('a missing --file is notFound', () async { + wire(); + final r = await show([], flags: {'file': 'gone.json'}); + expect(r.error?.kind, IpcErrorKind.notFound); + }); + test('--fullscreen rides along in the payload (T-252)', () async { wire(); final r = await show(['docs/diagram.png'], flags: {'fullscreen': true}); @@ -117,7 +142,9 @@ void main() { expect(spec['verb'], 'show'); expect(spec['positional'], ['path']); final args = spec['args'] as Map; - expect((args['path'] as Map)['required'], true); + // path is no longer required — it may come from a --file payload (T-316). + expect((args['path'] as Map)['required'], isNot(true)); expect(args.containsKey('caption'), isTrue); + expect(args.containsKey('file'), isTrue); }); }