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