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
@@ -10,6 +10,7 @@
library;
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
@@ -233,9 +234,63 @@ class _ConversationTurn extends StatelessWidget {
),
AssistantToolUse() => _toolUse(i),
ToolResultMessage() => _toolResult(i),
ImageMessage() => _image(i),
};
}
/// A driven-in image card (T-249): the image rendered inline, clide-owned
/// (Flutter's [Image.file], no third-party viewer), display-only per D-78.
/// Bounded so a large image scales down to the pane width and never pushes
/// past a readable height; a missing/unreadable file degrades to a muted
/// placeholder rather than throwing.
Widget _image(ImageMessage m) {
final caption = m.caption;
return ConversationCard(
accent: tokens.globalTextMuted,
label: 'image',
copyText: m.path,
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 360),
child: Image.file(
File(m.path),
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
),
),
),
if (caption != null && caption.isNotEmpty) ...[
const SizedBox(height: 4),
ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted),
],
],
),
);
}
Widget _imagePlaceholder(String path) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.image, size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
),
],
),
);
Widget _toolUse(AssistantToolUse t) {
// A resolved permission-prompted call: collapsed, green if approved / red
// if denied — a quiet record of what was permitted (D-78).
@@ -452,5 +507,7 @@ String _summarizeActivity(ConversationItem item) {
return text;
case AssistantTextMessage(:final text):
return text;
case ImageMessage(:final path):
return 'image $path';
}
}