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:
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user