add image-viewer card + clide image show verb
Drives an image inline into the Claude conversation log over the same bus-publish path as ui.toast/ui.open, keeping the dispatcher handler Flutter-free. The card is display-only per D-78; the verb registers a CommandSchema so it surfaces in clide capabilities for T-248 discovery. Closes T-249. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,5 +78,13 @@ void main() {
|
||||
test('empty input yields no groups', () {
|
||||
expect(groupConversation(const [], FoldLevel.tools), isEmpty);
|
||||
});
|
||||
|
||||
test('an image card stays first-class even at L3 (everything)', () {
|
||||
final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png');
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything);
|
||||
// The tool + result fold; the image is sticky and seals the cluster.
|
||||
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem']);
|
||||
expect((groups[1] as StickyItem).item, isA<ImageMessage>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Image, FileImage;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -29,6 +30,7 @@ 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);
|
||||
|
||||
class _MockClipboard {
|
||||
Map<String, dynamic> _data = {'text': null};
|
||||
@@ -187,6 +189,23 @@ void main() {
|
||||
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an image card renders with the "image" label, the file, and its caption (T-249)', (tester) async {
|
||||
await pumpWith(tester, [_image('/no/such/file.png', caption: 'before the fix')]);
|
||||
expect(find.text('image'), findsOneWidget);
|
||||
expect(find.text('before the fix'), findsOneWidget);
|
||||
// The image is wired to the resolved file path (display-only, D-78).
|
||||
final img = tester.widget<Image>(find.byType(Image));
|
||||
expect((img.image as FileImage).file.path, '/no/such/file.png');
|
||||
});
|
||||
|
||||
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);
|
||||
c.inject(_image('/no/such/shot.png'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('image'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/// Tests for `image.show` — the CLI that drives an image card into the Claude
|
||||
/// conversation log (T-249, D-6 parity). Verifies format validation, path
|
||||
/// resolution, the published MessageBus 'image' payload, and honest failure
|
||||
/// when the file is missing or there is no live UI.
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/image_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late List<({String publisher, String channel, Map<String, Object?> data})> published;
|
||||
late DaemonDispatcher d;
|
||||
|
||||
// [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}) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> show(List<String> positional, {Map<String, Object?>? flags}) => d.dispatch(
|
||||
IpcRequest(id: '1', cmd: 'image.show', args: {'positional': positional, if (flags != null) 'flags': flags}),
|
||||
);
|
||||
|
||||
test('resolves a workspace-relative path and publishes an image message', () async {
|
||||
wire();
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['shown'], isTrue);
|
||||
expect(r.data['path'], '/abs/docs/diagram.png');
|
||||
expect(published.single.publisher, 'cli');
|
||||
expect(published.single.channel, 'image');
|
||||
expect(published.single.data, {'path': '/abs/docs/diagram.png'});
|
||||
});
|
||||
|
||||
test('--caption rides along in the payload', () async {
|
||||
wire();
|
||||
final r = await show(['docs/diagram.png'], flags: {'caption': 'before the fix'});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data, {'path': '/abs/docs/diagram.png', 'caption': 'before the fix'});
|
||||
});
|
||||
|
||||
test('accepts the documented formats case-insensitively', () async {
|
||||
for (final name in ['a.PNG', 'b.jpg', 'c.jpeg', 'd.gif', 'e.webp', 'f.bmp']) {
|
||||
wire(found: {name});
|
||||
final r = await show([name]);
|
||||
expect(r.ok, isTrue, reason: '$name: ${r.error?.message}');
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported format → userError, nothing published', () async {
|
||||
wire(found: {'notes.txt'});
|
||||
final r = await show(['notes.txt']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('missing path → userError', () async {
|
||||
wire();
|
||||
final r = await show([]);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('a file that does not resolve → notFound, nothing published', () async {
|
||||
wire(found: const {});
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.notFound);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('no live UI (null publisher) → toolError, not a hang', () async {
|
||||
wire(liveUi: false);
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
|
||||
test('a leading-dash path is rejected by the schema (argv-injection guard)', () async {
|
||||
wire();
|
||||
final r = await show(['-rf.png']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
});
|
||||
|
||||
test('null resolver passes the path through unverified (headless)', () async {
|
||||
wire(withResolver: false);
|
||||
final r = await show(['docs/diagram.png']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data, {'path': 'docs/diagram.png'});
|
||||
});
|
||||
|
||||
test('image.show appears in the capabilities discovery surface (T-248)', () async {
|
||||
wire();
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'capabilities', args: const {}));
|
||||
expect(r.ok, isTrue);
|
||||
final commands = r.data['commands'] as Map<String, Object?>;
|
||||
expect(commands.containsKey('image.show'), isTrue);
|
||||
final spec = commands['image.show'] as Map<String, Object?>;
|
||||
expect(spec['subsystem'], 'image');
|
||||
expect(spec['verb'], 'show');
|
||||
expect(spec['positional'], ['path']);
|
||||
final args = spec['args'] as Map<String, Object?>;
|
||||
expect((args['path'] as Map)['required'], true);
|
||||
expect(args.containsKey('caption'), isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user