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
+3 -1
View File
@@ -88,9 +88,11 @@ List<RenderGroup> groupConversation(List<ConversationItem> items, FoldLevel leve
bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> toolName) {
if (level == FoldLevel.none) return false;
switch (item) {
// User prose and Claude prose are always first-class.
// User prose, Claude prose, and driven-in image cards are always
// first-class — an image is the point of the turn, never folded away.
case UserMessage():
case AssistantTextMessage():
case ImageMessage():
return false;
// Thinking folds at L2+, first-class at L1.
case AssistantThinkingMessage():
@@ -71,6 +71,15 @@ class ConversationController extends ChangeNotifier {
bool get isEmpty => _items.isEmpty;
/// Append a locally-produced item that did not come from the transcript
/// stream — e.g. an image card driven by `clide image show` (T-249). It
/// lands in arrival order and notifies listeners exactly like a streamed
/// item, so the view renders it inline. No-op once disposed.
void inject(ConversationItem item) {
if (_disposed) return;
_onItem(item);
}
void _onItem(ConversationItem item) {
// Track AssistantToolUse items by toolUseId for result-card pairing (T-168).
if (item is AssistantToolUse) {
@@ -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';
}
}
+24
View File
@@ -10,6 +10,8 @@ import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
import 'package:clide/builtin/claude/src/team_panel_host.dart';
import 'package:clide/extension/extension.dart';
@@ -321,6 +323,28 @@ class ClaudeExtension extends ClideExtension {
// session outlives its pane and is shared across surfaces.
_orchestrator = ClaudeSessionOrchestrator();
activeSessionOrchestrator = _orchestrator;
// `clide image show <path>` (T-249): the dispatcher resolves + publishes an
// 'image' message; we inject the matching card into the conversation the
// user is looking at (the primary lead, else the first visible session).
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
}
/// Inject an [ImageMessage] from a published `image` bus message (T-249).
/// Dropped silently if no live conversation is available — the CLI already
/// reported success at publish time, and a missing pane is transient.
void _onImageShow(Message m) {
final path = m.data['path'] as String?;
if (path == null || path.isEmpty) return;
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return;
target.conversation.inject(ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
));
}
@override
@@ -141,6 +141,30 @@ final class AssistantToolUse extends ConversationItem {
String toString() => 'AssistantToolUse(name=$name, id=$toolUseId)';
}
/// A locally-injected image card (T-249). Not parsed from the transcript —
/// driven into the conversation by `clide image show <path>` (D-6 parity) and
/// rendered display-only per D-78. [path] is an absolute, on-disk file the
/// 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,
});
/// Absolute path to the image file on disk.
final String path;
/// Optional caption shown under the image.
final String? caption;
@override
String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})';
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
+14 -1
View File
@@ -28,13 +28,14 @@ import 'package:clide/builtin/vim/vim.dart';
import 'package:clide/builtin/tickets/tickets.dart';
import 'package:clide/builtin/todos/todos.dart';
import 'package:clide/builtin/welcome/welcome.dart';
import 'dart:io' show Directory, Platform;
import 'dart:io' show Directory, File, Platform;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/image_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/status_command.dart';
import 'package:clide/src/daemon/ui_command.dart';
@@ -215,6 +216,18 @@ Future<void> main() async {
// (T-231, drive-half of D-6). Publishes a 'selection' to the kernel
// MessageBus, captured post-boot; null in headless contexts.
registerUiCommands(dispatcher, () => kernelMessages?.publish);
// `clide image show <path>` — drive an image card into the Claude
// conversation log (T-249, drive-half of D-6). Resolves the path
// (workspace-relative → absolute, must exist) here where workRoot is in
// scope, then publishes an 'image' message the Claude extension injects.
registerImageCommands(
dispatcher,
() => kernelMessages?.publish,
resolve: (path) {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
return file.existsSync() ? file.absolute.path : null;
},
);
// `clide status` — one-shot orientation snapshot (T-221): active pane,
// focused file + selection, git summary, layout. Assembled here where the
// live kernel + subsystem state is in scope; the reader's viewed doc is
+123
View File
@@ -0,0 +1,123 @@
/// Registers `image.show` — drive an image card into the Claude conversation
/// log from the CLI (T-249, D-6 parity).
///
/// clide image show docs/wireframes/hud.png
/// clide image show /abs/path/shot.jpg --caption "before the fix"
///
/// The card itself is clide-owned rendering in the conversation view; this is
/// its CLI counterpart. Like `ui.open` / `ui.toast`, the handler is decoupled
/// from the live UI: it validates the request, resolves the path to a real
/// on-disk file via an injected [ImagePathResolver], then publishes an `image`
/// message on the kernel MessageBus (captured post-boot in main.dart). A
/// consumer in the Claude extension injects the matching [ImageMessage] into
/// the primary session's conversation. Flutter-free so it runs under
/// `dart test`.
library;
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import 'dispatcher.dart';
import 'ui_command.dart' show MessagePublisher;
/// Image formats `image.show` accepts, matched on the path's extension. Mirrors
/// the composer's attachment sniff so what you can paste in, you can show.
const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
/// Resolves a user-supplied image path (absolute or workspace-relative) to an
/// absolute path to an existing file, or null when no such file exists.
/// Injected so this file stays Flutter-free and unit-testable without touching
/// the real filesystem; main.dart wires it to the workspace root + `File`.
typedef ImagePathResolver = 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,
}) {
d.register(
'image.show',
(req) async => _show(req, publisher, resolve),
schema: const CommandSchema(
positional: ['path'],
args: {
'path': ArgSpec(required: true, rejectLeadingDash: true),
'caption': ArgSpec(),
},
),
);
}
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
id: id,
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?;
if (path == null || path.trim().isEmpty) {
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)');
}
final ext = _extensionOf(path);
if (!imageShowExtensions.contains(ext)) {
return _userErr(
req.id,
'unsupported image format${ext.isEmpty ? '' : ' ".$ext"'}',
hint: 'one of: ${(imageShowExtensions.toList()..sort()).join(', ')}',
);
}
// Resolve to a concrete file before publishing, so the CLI fails honestly on
// a typo instead of silently showing a broken card. A null resolver (headless
// tests) passes the path through unverified.
String resolved = path;
if (resolve != null) {
final abs = resolve(path);
if (abs == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such image: $path',
hint: 'path is resolved relative to the workspace root',
),
);
}
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.
return IpcResponse.err(
id: req.id,
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
);
}
publish('cli', imageShowChannel, {
'path': resolved,
if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(),
});
return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'shown': true});
}
/// Lower-cased extension (without the dot) of [path], or '' if none.
String _extensionOf(String path) {
final slash = path.lastIndexOf('/');
final dot = path.lastIndexOf('.');
if (dot <= 0 || dot < slash || dot == path.length - 1) return '';
return path.substring(dot + 1).toLowerCase();
}