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:
@@ -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