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
@@ -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)],
],
),
+2
View File
@@ -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?,
),
);
}
+13 -1
View File
@@ -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"' : ''})';
}
+8
View File
@@ -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
+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('/');