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:
2026-06-29 12:58:30 +02:00
co-authored by Claude Opus 4.8
parent 5f8fa6af10
commit f0fb5a5134
10 changed files with 207 additions and 17 deletions
+56 -9
View File
@@ -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('/');