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:
@@ -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('/');
|
||||
|
||||
Reference in New Issue
Block a user