feat(image): --file metadata payload for annotated image cards (T-316)
`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 <path> [--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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.<name> + 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;
|
||||
|
||||
@@ -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.<name> + 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 <path.json>` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.:
|
||||
|
||||
{ "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" }
|
||||
|
||||
Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show <path> --caption "…"` form working unchanged (back-compat); the JSON payload is additive.
|
||||
|
||||
## Open decision — text vs visual annotation
|
||||
|
||||
''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready.
|
||||
- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption.
|
||||
- ImageMessage + the image card render the added metadata.
|
||||
- The existing `image show <path> [--caption]` form is unchanged.
|
||||
- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation.
|
||||
- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', '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);
|
||||
|
||||
@@ -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 <path> --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
|
||||
|
||||
@@ -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)],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"' : ''})';
|
||||
}
|
||||
|
||||
@@ -368,6 +368,14 @@ Future<void> 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 <doc>` — drive a drawing card into the Claude
|
||||
// conversation (T-318, drive-half of D-6). Reads the JSON doc relative to
|
||||
|
||||
@@ -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<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}) {
|
||||
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<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve) async {
|
||||
final path = req.args['path'] as String?;
|
||||
Future<IpcResponse> _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 <json>: an annotation payload {path,label,description,caption}
|
||||
// (T-316). Additive — the bare `image show <path> [--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<IpcResponse> _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<IpcResponse> _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('/');
|
||||
|
||||
@@ -35,7 +35,8 @@ 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);
|
||||
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<String, dynamic> _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);
|
||||
|
||||
@@ -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<String> found = const {'docs/diagram.png'}, bool withResolver = true}) {
|
||||
void wire({bool liveUi = true, Set<String> found = const {'docs/diagram.png'}, bool withResolver = true, Map<String, String> 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<IpcResponse> show(List<String> positional, {Map<String, Object?>? 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<String, Object?>;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user