diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index a49929d4..b2ad80d9 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -47,6 +47,7 @@ "conversation.label.thinking": { "translation": "thinking" }, "conversation.label.agentThinking": { "translation": "agent thinking" }, "conversation.label.image": { "translation": "image" }, + "conversation.label.drawing": { "translation": "drawing" }, "conversation.label.agentRun": { "translation": "agent run" }, "conversation.label.workflow": { "translation": "workflow" }, "conversation.label.error": { "translation": "error" }, diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index 1084fd9a..8718f993 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -47,6 +47,7 @@ "conversation.label.thinking": { "translation": "nadenken" }, "conversation.label.agentThinking": { "translation": "agent denkt na" }, "conversation.label.image": { "translation": "afbeelding" }, + "conversation.label.drawing": { "translation": "tekening" }, "conversation.label.agentRun": { "translation": "agent-uitvoering" }, "conversation.label.workflow": { "translation": "workflow" }, "conversation.label.error": { "translation": "fout" }, diff --git a/lib/builtin/claude/src/activity_cluster.dart b/lib/builtin/claude/src/activity_cluster.dart index ff557c42..58b71545 100644 --- a/lib/builtin/claude/src/activity_cluster.dart +++ b/lib/builtin/claude/src/activity_cluster.dart @@ -162,6 +162,7 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map too case UserMessage(): case AssistantTextMessage(): case ImageMessage(): + case DrawingMessage(): return false; // Thinking folds at L2+, first-class at L1. case AssistantThinkingMessage(): diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 1c7bbed4..c4e475a7 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -22,6 +22,8 @@ import 'package:clide/builtin/claude/src/file_tail_follower.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/src/svg/svg_document.dart' show buildSvgDocument; +import 'package:clide/widgets/src/draw/drawing_card.dart'; import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/keymap/intents.dart'; @@ -701,9 +703,21 @@ class _ConversationTurn extends StatelessWidget { AssistantToolUse() => collapseTools ? _toolUseCollapser(context, i) : _toolContentCard(context, i), ToolResultMessage() => _toolResult(context, i), ImageMessage() => _image(context, i), + DrawingMessage() => _drawing(context, i), }; } + /// A driven-in drawing card (T-318): the SVG rendered inline by clide's own + /// CustomPaint engine (D-103), display-only per D-78, with an optional + /// label/description caption. + Widget _drawing(BuildContext context, DrawingMessage m) { + return ConversationCard( + accent: tokens.globalTextMuted, + label: ClideSettings.i18n.string(context, 'conversation.label.drawing', namespace: 'builtin.claude', placeholder: 'drawing'), + body: DrawingCard(document: buildSvgDocument(m.svg), label: m.label, description: m.description), + ); + } + /// 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 @@ -1277,5 +1291,7 @@ String _summarizeActivity(BuildContext context, ConversationItem item) { return text; case ImageMessage(:final path): return '${label('conversation.label.image', 'image')} $path'; + case DrawingMessage(label: final cardLabel): + return '${label('conversation.label.drawing', 'drawing')}${cardLabel != null ? ' $cardLabel' : ''}'; } } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 9578517e..a76c62fa 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -19,9 +19,10 @@ import 'package:clide/builtin/claude/src/session_index.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes; import 'package:clide/builtin/claude/src/session_storage.dart'; import 'package:clide/builtin/claude/src/ticket_pick_up.dart'; -import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage; +import 'package:clide/builtin/claude/src/transcript_reader.dart' show DrawingMessage, ImageMessage; import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel; import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; +import 'package:clide/src/daemon/draw_commands.dart' show drawShowChannel; 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'; @@ -524,6 +525,11 @@ class ClaudeExtension extends ClideExtension { // user is looking at (the primary lead, else the first visible session). _subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow)); + // `clide draw --file ` (T-318): the dispatcher lowers the doc to SVG + // and publishes a 'draw' message; we inject the drawing card into the + // conversation the user is looking at. + _subs.add(ctx.messages.subscribe(channel: drawShowChannel).listen(_onDrawShow)); + // A sidebar "pick up" click (T-327) publishes the full ticket; inject it // into the active conversation as a user turn so Claude starts working it. _subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp)); @@ -639,6 +645,26 @@ class ClaudeExtension extends ClideExtension { ); } + /// Inject a [DrawingMessage] from a published `draw` bus message (T-318). + /// Dropped silently if no live conversation is available — the CLI already + /// reported success at publish time, and a missing pane is transient. + void _onDrawShow(Message m) { + final svg = m.data['svg'] as String?; + if (svg == null || svg.isEmpty) return; + final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull; + if (target == null) return; + target.conversation.inject( + DrawingMessage( + uuid: 'draw-${DateTime.now().microsecondsSinceEpoch}', + timestamp: DateTime.now(), + isSidechain: false, + svg: svg, + label: m.data['label'] as String?, + description: m.data['description'] as String?, + ), + ); + } + @override Future deactivate() async { for (final s in _subs) { diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index ddcd1427..54a24bf8 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -190,6 +190,24 @@ final class ImageMessage extends ConversationItem { String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})'; } +/// A locally-injected drawing card (T-318). Not parsed from the transcript — +/// driven into the conversation by `clide draw --file ` (D-6 parity) and +/// rendered display-only per D-78. [svg] is the SVG substrate the renderer +/// paints (already lowered from the doc's template / primitive source); +/// [label] / [description] are the optional card caption. +final class DrawingMessage extends ConversationItem { + const DrawingMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.svg, this.label, this.description}); + + /// The SVG document source the renderer paints. + final String svg; + + /// Optional card-level caption (label + supporting description). + final String? label, description; + + @override + String toString() => 'DrawingMessage(${label ?? ''})'; +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/lib/main.dart b/lib/main.dart index 095fddfd..2496bf5f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,6 +39,7 @@ import 'package:clide/builtin/claude/src/account_registry.dart'; import 'package:clide/clide.dart' show clideVersion; import 'package:clide/src/daemon/claude_account_commands.dart'; import 'package:clide/src/daemon/dispatcher.dart'; +import 'package:clide/src/daemon/draw_commands.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'; @@ -361,6 +362,24 @@ Future main() async { return file.existsSync() ? file.absolute.path : null; }, ); + // `clide draw --file ` — drive a drawing card into the Claude + // conversation (T-318, drive-half of D-6). Reads the JSON doc relative to + // workRoot, lowers it to SVG via the template registry (primitive svg now; + // d2/icon/compare/image handlers register as they land), then publishes a + // 'draw' message the Claude extension injects. + registerDrawCommands( + dispatcher, + () => kernelMessages?.publish, + registry: DrawingRegistry(), + readFile: (path) async { + final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); + try { + return file.existsSync() ? await file.readAsString() : null; + } catch (_) { + return null; + } + }, + ); // `clide claude account …` — manage per-repo Claude accounts (T-480, epic // T-476). Registry reads/writes go through the user-scope SettingsStore; // side-effects (respawn, login pane, --purge) are published on diff --git a/lib/src/daemon/draw_commands.dart b/lib/src/daemon/draw_commands.dart index 510e51c8..83b3729c 100644 --- a/lib/src/daemon/draw_commands.dart +++ b/lib/src/daemon/draw_commands.dart @@ -17,6 +17,8 @@ import 'dart:convert'; import '../draw/draw_dispatch.dart'; import '../draw/draw_doc.dart'; + +export '../draw/draw_dispatch.dart' show DrawingFileReader, DrawingRegistry, DrawingTemplateHandler; import '../ipc/command_schema.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 9a1c6cc9..84684268 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -326,6 +326,31 @@ void main() { expect(find.byKey(const ValueKey('turn.B')), findsOneWidget); }); + testWidgets('a driven-in drawing card renders the SVG + caption (T-318)', (tester) async { + tester.view.physicalSize = const Size(900, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final stream = StreamController.broadcast(); + final c = ConversationController(stream: stream.stream); + addTearDown(c.dispose); + await tester.pumpWidget(harness(f, ConversationView(controller: c, foldLevel: FoldLevel.none))); + stream.add( + DrawingMessage( + uuid: 'D', + timestamp: _t, + isSidechain: false, + svg: '', + label: 'Build pipeline', + description: 'how it connects', + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('turn.D')), findsOneWidget); + expect(find.text('Build pipeline'), findsOneWidget); + expect(find.text('how it connects'), findsOneWidget); + }); + testWidgets('a folded activity cluster carries a stable identity key (T-285)', (tester) async { tester.view.physicalSize = const Size(900, 800); tester.view.devicePixelRatio = 1.0;