feat(draw): clide draw command — drive a drawing card from the CLI (T-318)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:18:53 +02:00
co-authored by Claude Opus 4.8
parent ed5cd6e312
commit 14bbd1fd2d
4 changed files with 193 additions and 0 deletions
+1
View File
@@ -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 ('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 ('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 ('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);
+1
View File
@@ -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. 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); 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);
+91
View File
@@ -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<IpcResponse> _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});
}
+100
View File
@@ -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<String, Object?> data})> published;
late DaemonDispatcher d;
late DrawingRegistry registry;
late Map<String, String> 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<IpcResponse> 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":"<svg id=\\"x\\"/>"}';
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'], '<svg id="x"/>');
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 => '<svg data-d2="${doc.fields['source']}"/>');
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'], '<svg data-d2="a -> b"/>');
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":"<svg/>"}';
final r = await draw('card.json');
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
expect(published, isEmpty);
});
}