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:
2026-06-06 10:22:38 +02:00
co-authored by Claude Opus 4.8
parent 3cf77f40ed
commit b446bd024d
13 changed files with 515 additions and 2 deletions
@@ -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,