From 14bbd1fd2d3879ee293e3366eee784b1bb5ec6a1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 21:55:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(draw):=20`clide=20draw`=20command=20?= =?UTF-8?q?=E2=80=94=20drive=20a=20drawing=20card=20from=20the=20CLI=20(T-?= =?UTF-8?q?318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors image.show: a Flutter-free `draw` handler reads + JSON-parses the document (injected reader), lowers it to SVG via the template registry, and publishes {svg, label, description} on the `draw` MessageBus channel for the Claude extension to inject. Honest errors — notFound (missing file), userError (bad JSON / unknown template / no --file), toolError (no live UI). Covered by dart test (7 cases). The extension subscription that injects the card remains to wire. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_idmap/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 1 + lib/src/daemon/draw_commands.dart | 91 +++++++++++++++++++++ test/daemon/draw_commands_test.dart | 100 ++++++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 lib/src/daemon/draw_commands.dart create mode 100644 test/daemon/draw_commands_test.dart diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index f5b2a9d4..962b0ce2 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -324,3 +324,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'T-494', '2026-06-28 14:01:13.733', '2026-06-28 14:01:13.733', NULL, '37bf4baec69cb3d06072793cd3d60a46', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'T-495', '2026-06-28 17:50:02.366', '2026-06-28 17:50:02.366', NULL, '40dbd25d323a8192e8f65a6e1c2dd33d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'T-496', '2026-06-28 18:00:41.420', '2026-06-28 18:00:41.420', NULL, 'd2e472745c710eb00555b2b824d4e214', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZK6PWBRBK9J2XPJ55Y3630', 'T-497', '2026-06-28 19:55:10.439', '2026-06-28 19:55:10.439', NULL, '56f8bf5d0e9a52d9ce3c7a293b5f8972', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 0ca429e7..5df2e27e 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10048,3 +10048,4 @@ SCHEMA DRAFTED (2026-06-28): docs/design/drawing-card-schema.md — declarative MODEL CLARIFICATION (user): we build clide''s OWN native Flutter CustomPaint interpreting a declarative scene-graph document. We do NOT port the HTML Canvas 2D API — ''HTML canvas'' in D-91 is only the mental model (a general drawing surface, chosen to reject Obsidian''s .canvas schema), not an API to implement. The declarative model is closer to SVG/a retained scene-graph than to canvas''s imperative 2D context. RESCOPED (D-103, 2026-06-28): the primitive layer is now SVG and the SVG renderer (T-320) is the engine — so T-318 is NO LONGER a primitive renderer. T-318 = the document envelope ({template? | svg/svgPath}, card metadata), the template DISPATCH, and the clide FLUTTER OVERLAY: per-object label/description caption widgets + lightbox affordance, anchored to SVG elements via data-label / data-description / data-lightbox. Now blocked by T-320 (engine first). Schema: docs/design/drawing-card-schema.md.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:40', '2026-06-28 19:36:13.943', NULL, '9cb51abdf14d5fc47d0bf25d75e79a68', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZK6PWBRBK9J2XPJ55Y3630', 'initiative', NULL, 'Add Vibe CLI support to clide as opt-in alternative LLM', NULL, 'backlog', 'high', NULL, NULL, 'D-105', '2026-06-28 19:55:10.434', '2026-06-28 19:55:10.434', NULL, '280dbe8eed154885c65dc0ddd2f48f53', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/src/daemon/draw_commands.dart b/lib/src/daemon/draw_commands.dart new file mode 100644 index 00000000..510e51c8 --- /dev/null +++ b/lib/src/daemon/draw_commands.dart @@ -0,0 +1,91 @@ +/// Registers `draw` — drive a drawing card into the Claude conversation from +/// the CLI (T-318, D-91 / D-103, D-6 parity). +/// +/// clide draw --file card.json +/// +/// The card is clide-owned rendering (the SVG engine + the [DrawingCard] +/// widget); this is its CLI counterpart. Mirroring `image.show`, the handler is +/// decoupled from the live UI: it reads + parses the JSON document (via an +/// injected [DrawingFileReader]), lowers it to an SVG string through the +/// template [DrawingRegistry], then publishes a `draw` message on the kernel +/// MessageBus. A consumer in the Claude extension builds the SvgDocument and +/// injects the card into the primary session. Flutter-free so it runs under +/// `dart test`. +library; + +import 'dart:convert'; + +import '../draw/draw_dispatch.dart'; +import '../draw/draw_doc.dart'; +import '../ipc/command_schema.dart'; +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import 'dispatcher.dart'; +import 'ui_command.dart' show MessagePublisher; + +/// The MessageBus channel `draw` publishes on; the Claude extension subscribes +/// to the same literal to inject the card. +const drawShowChannel = 'draw'; + +void registerDrawCommands( + DaemonDispatcher d, + MessagePublisher? Function() publisher, { + required DrawingRegistry registry, + required DrawingFileReader readFile, +}) { + d.register( + 'draw', + (req) async => _draw(req, publisher, registry, readFile), + schema: const CommandSchema(args: {'file': ArgSpec(required: true, rejectLeadingDash: true)}), + ); +} + +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 _draw(IpcRequest req, MessagePublisher? Function() publisherSource, DrawingRegistry registry, DrawingFileReader readFile) async { + final file = req.args['file'] as String?; + if (file == null || file.trim().isEmpty) { + return _userErr(req.id, 'a drawing-card document is required (e.g. `draw --file card.json`)'); + } + + final raw = await readFile(file); + if (raw == null) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: 'no such file: $file', + hint: 'path is resolved relative to the workspace root', + ), + ); + } + + Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + } + + final doc = parseDrawingCardDoc(decoded); + if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object'); + + final result = await resolveDrawingSvg(doc, registry, readFile: readFile); + if (result is DrawErr) return _userErr(req.id, result.message); + final svg = (result as DrawOk).svg; + + final publish = publisherSource(); + if (publish == null) { + 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', drawShowChannel, {'svg': svg, if (doc.label != null) 'label': doc.label, if (doc.description != null) 'description': doc.description}); + return IpcResponse.ok(id: req.id, data: {'shown': true, if (doc.template != null) 'template': doc.template}); +} diff --git a/test/daemon/draw_commands_test.dart b/test/daemon/draw_commands_test.dart new file mode 100644 index 00000000..9237ae5e --- /dev/null +++ b/test/daemon/draw_commands_test.dart @@ -0,0 +1,100 @@ +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/draw_commands.dart'; +import 'package:clide/src/draw/draw_dispatch.dart'; +import 'package:test/test.dart'; + +void main() { + late List<({String publisher, String channel, Map data})> published; + late DaemonDispatcher d; + late DrawingRegistry registry; + late Map files; + + setUp(() { + published = []; + files = {}; + registry = DrawingRegistry(); + d = DaemonDispatcher(); + }); + + void wire({bool liveUi = true}) => registerDrawCommands( + d, + () => liveUi ? (pub, ch, data) => published.add((publisher: pub, channel: ch, data: data)) : null, + registry: registry, + readFile: (p) async => files[p], + ); + + Future draw(String? file) => d.dispatch( + IpcRequest( + id: '1', + cmd: 'draw', + args: { + 'positional': const [], + 'flags': {'file': ?file}, + }, + ), + ); + + test('primitive doc: publishes the svg + captions on the draw channel', () async { + wire(); + files['card.json'] = '{"card":{"label":"Pipeline","description":"how"},"svg":""}'; + final r = await draw('card.json'); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['shown'], isTrue); + expect(published.single.publisher, 'cli'); + expect(published.single.channel, drawShowChannel); + expect(published.single.data['svg'], ''); + expect(published.single.data['label'], 'Pipeline'); + expect(published.single.data['description'], 'how'); + }); + + test('template doc lowers via the registry before publishing', () async { + wire(); + registry.register('d2', (doc) async => ''); + files['c.json'] = '{"template":"d2","source":"a -> b"}'; + final r = await draw('c.json'); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data['svg'], ''); + expect(r.data['template'], 'd2'); + }); + + test('a missing file → notFound, nothing published', () async { + wire(); + final r = await draw('nope.json'); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.notFound); + expect(published, isEmpty); + }); + + test('invalid JSON → userError, nothing published', () async { + wire(); + files['bad.json'] = 'not json'; + final r = await draw('bad.json'); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('an unknown template → userError', () async { + wire(); + files['c.json'] = '{"template":"nope"}'; + final r = await draw('c.json'); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + }); + + test('missing --file → userError (schema-required)', () async { + wire(); + final r = await draw(null); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + }); + + test('no live UI → toolError, not a hang', () async { + wire(liveUi: false); + files['card.json'] = '{"svg":""}'; + final r = await draw('card.json'); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.toolError); + expect(published, isEmpty); + }); +}