diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 8cb1db02..3ef01aa9 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -362,3 +362,39 @@ DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+sh INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'status', 'ready', 'done', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '121337ddae4598659f07bbe16c1ddcc3', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-250', 'status', 'ready', 'done', NULL, '2026-06-06 07:49:08', '2026-06-06 07:49:08', '2026-06-06 07:49:08', NULL, '0765a2ccbb37deedb402d06709511928', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-248', 'status', 'ready', 'done', NULL, '2026-06-06 07:57:16', '2026-06-06 07:57:16', '2026-06-06 07:57:16', NULL, 'b36bb78451373dae70a8b5f1f0e16ace', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-208', 'status', 'backlog', 'ready', NULL, '2026-06-06 08:06:55', '2026-06-06 08:06:55', '2026-06-06 08:06:55', NULL, 'c6035deb44dfefa54618b264a5bb4cad', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-208', 'status', 'ready', 'in_progress', NULL, '2026-06-06 08:12:45', '2026-06-06 08:12:45', '2026-06-06 08:12:45', NULL, '1a99ebcbec47b3377fd43373f9c01876', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-209', 'status', 'backlog', 'in_progress', NULL, '2026-06-06 08:12:45', '2026-06-06 08:12:45', '2026-06-06 08:12:45', NULL, '9a84a354ef89306ae8b3f0835c380a4b', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-248', 'description', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable. + +Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide ` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there. + +Scope: +- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts. +- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct. + +This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel. + +Refs: D-6 (CLI/event-surface parity). Related: T-249.', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable. + +Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide ` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there. + +Scope: +- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts. +- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct. + +This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel. + +Refs: D-6 (CLI/event-surface parity). Related: T-249. + +RE-SCOPE (2026-06-06, from T-208 completeness review): partly overtaken by reality — do NOT build from scratch. +- The `clide capabilities` verb already EXISTS in code: registered in lib/src/daemon/dispatcher.dart:12 (built-in) and as an umbrella command in lib/src/cli/argv_to_request.dart:28. Its handler (_capabilities, dispatcher.dart:86) enumerates the live registry with arg schemas — so the non-rotting design is already implemented server-side. +- A `/clide` skill is ALSO already present this session (other ongoing work), and it instructs agents to "Start with `clide capabilities` to enumerate the live tool surface." +- BUT: against the live instance, `clide capabilities` returns a usage stub at exit 0 (`usage: clide capabilities [args...]`) instead of the command table — i.e. the discoverability entry point the skill advertises is currently DEAD. Likely a stale running build or a C-client umbrella mismatch (native/clide-cli/clide.c forwards argv but the live server treats `capabilities` as a subsystem needing a verb). + +New scope for this ticket: +1. Verify/fix the live `clide capabilities` path end-to-end (rebuild/restart vs real wiring gap in the C client / server umbrella handling). Acceptance: `clide capabilities` returns the JSON command table against a running instance. +2. Reconcile with the already-shipped `/clide` skill rather than authoring anew — confirm it points at the working verb and covers the conventions. +Net: this is now a verify-and-reconcile task, not a build-from-scratch one. Related: T-249, T-208.', NULL, '2026-06-06 08:12:53', '2026-06-06 08:12:53', '2026-06-06 08:12:53', NULL, '44685b2fe0265e9e8b110d4306de92be', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-248', 'status', 'done', 'review', NULL, '2026-06-06 08:14:01', '2026-06-06 08:14:01', '2026-06-06 08:14:01', NULL, '1cfdde30e08e7e18f8b9eb39df3abcbc', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-249', 'status', 'ready', 'done', NULL, '2026-06-06 08:21:40', '2026-06-06 08:21:40', '2026-06-06 08:21:40', NULL, '996d52afaf650a7d5021184a673d09a3', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index fa247d80..98880877 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -815,3 +815,76 @@ Scope: This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel. Refs: D-6 (CLI/event-surface parity). Related: T-249.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:57:16', NULL, 'f6a976eef4c722612111256e339c8f11', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-208', 'initiative', NULL, 'Give Claude hands', 'Make a Claude agent able to actually WORK inside clide: reach the CLI, know clide is hosting it, and observe the same workspace the user sees. Source: self-analysis.md (2026-06-02), a live dogfood probe by a Claude (Opus 4.8) running against a live instance (socket + MCP SSE up, version 2.1.0). + +Finding: the IPC/CLI foundation (D-1, D-6, D-56; built under T-99 incl. the C client T-126 and the `clide tail --events` stream T-129) works end-to-end at the socket level. But a fresh agent cannot REACH or OBSERVE it. Three blockers: +1. No `clide` binary on PATH, and no install path that delivers one (Epic A — blocker). +2. No bootstrap: nothing tells a fresh agent clide is running or where its socket is (Epic B). +3. The live UI panes / active file are not reflected in the CLI registries, so the D-6 parity premise (agent sees what the user sees) breaks (Epic C). + +Plus: a governance decision on WHICH agent model is the dogfood target (hosted stream-json session vs external CLI driver vs both) and an MCP follow-up. The pleasant surprise per the probe: the hard part (a live, correct, single-process IPC contract with proper 0/1/3 exit codes) is done; what''s missing is the last mile that puts the tool in the agent''s hands. NOTE: the report says ''daemon'' but D-56 dissolved it — this is the in-process IPC server; the socket contract is unchanged.', 'ready', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 08:06:55', NULL, '021035195ac2b960d7ac7f3aa35efcf9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-209', 'epic', 'T-208', 'Ship the clide CLI on PATH', 'Gap 1 (BLOCKER) from self-analysis.md. The C client native/clide-cli/clide.c (T-126, done) is the real CLI, but there is no supported way for `clide` to land on an agent''s PATH: +- `make clide-cli` (Makefile:284) is a non-default target; `build` (Makefile:169) does not depend on it, so the binary is never produced by a normal build (native/macos-arm64/clide had never been built). +- `make install` (Makefile:195) copies the GUI bundle and `ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide` (Makefile:200) symlinks the FLUTTER GUI runner — not the C client — on BOTH macOS and Linux (on macOS that path doesn''t even resolve inside clide.app). + +Net: `which clide` -> not found, `clide ping` -> exit 127. The CLI-first contract (D-1) has no delivery mechanism. Related to the broader installer epic T-46, but that is the GUI/runtime installer; this epic is specifically C-client-on-PATH delivery.', 'in_progress', 'high', NULL, NULL, 'D-1', '2026-06-02 18:13:52', '2026-06-06 08:12:45', NULL, 'b6572a0ff80e81002cb8ce71550e8e08', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-208', 'initiative', NULL, 'Give Claude hands', 'Make a Claude agent able to actually WORK inside clide: reach the CLI, know clide is hosting it, and observe the same workspace the user sees. Source: self-analysis.md (2026-06-02), a live dogfood probe by a Claude (Opus 4.8) running against a live instance (socket + MCP SSE up, version 2.1.0). + +Finding: the IPC/CLI foundation (D-1, D-6, D-56; built under T-99 incl. the C client T-126 and the `clide tail --events` stream T-129) works end-to-end at the socket level. But a fresh agent cannot REACH or OBSERVE it. Three blockers: +1. No `clide` binary on PATH, and no install path that delivers one (Epic A — blocker). +2. No bootstrap: nothing tells a fresh agent clide is running or where its socket is (Epic B). +3. The live UI panes / active file are not reflected in the CLI registries, so the D-6 parity premise (agent sees what the user sees) breaks (Epic C). + +Plus: a governance decision on WHICH agent model is the dogfood target (hosted stream-json session vs external CLI driver vs both) and an MCP follow-up. The pleasant surprise per the probe: the hard part (a live, correct, single-process IPC contract with proper 0/1/3 exit codes) is done; what''s missing is the last mile that puts the tool in the agent''s hands. NOTE: the report says ''daemon'' but D-56 dissolved it — this is the in-process IPC server; the socket contract is unchanged.', 'in_progress', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-06 08:12:45', NULL, 'ebd93ec3f2bcf8381bd4131117527357', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-248', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable. + +Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide ` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there. + +Scope: +- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts. +- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct. + +This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel. + +Refs: D-6 (CLI/event-surface parity). Related: T-249. + +RE-SCOPE (2026-06-06, from T-208 completeness review): partly overtaken by reality — do NOT build from scratch. +- The `clide capabilities` verb already EXISTS in code: registered in lib/src/daemon/dispatcher.dart:12 (built-in) and as an umbrella command in lib/src/cli/argv_to_request.dart:28. Its handler (_capabilities, dispatcher.dart:86) enumerates the live registry with arg schemas — so the non-rotting design is already implemented server-side. +- A `/clide` skill is ALSO already present this session (other ongoing work), and it instructs agents to "Start with `clide capabilities` to enumerate the live tool surface." +- BUT: against the live instance, `clide capabilities` returns a usage stub at exit 0 (`usage: clide capabilities [args...]`) instead of the command table — i.e. the discoverability entry point the skill advertises is currently DEAD. Likely a stale running build or a C-client umbrella mismatch (native/clide-cli/clide.c forwards argv but the live server treats `capabilities` as a subsystem needing a verb). + +New scope for this ticket: +1. Verify/fix the live `clide capabilities` path end-to-end (rebuild/restart vs real wiring gap in the C client / server umbrella handling). Acceptance: `clide capabilities` returns the JSON command table against a running instance. +2. Reconcile with the already-shipped `/clide` skill rather than authoring anew — confirm it points at the working verb and covers the conventions. +Net: this is now a verify-and-reconcile task, not a build-from-scratch one. Related: T-249, T-208.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:12:53', NULL, 'b644ca71025fba6601ccb7ea5fa5cc18', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-248', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable. + +Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide ` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there. + +Scope: +- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts. +- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct. + +This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel. + +Refs: D-6 (CLI/event-surface parity). Related: T-249. + +RE-SCOPE (2026-06-06, from T-208 completeness review): partly overtaken by reality — do NOT build from scratch. +- The `clide capabilities` verb already EXISTS in code: registered in lib/src/daemon/dispatcher.dart:12 (built-in) and as an umbrella command in lib/src/cli/argv_to_request.dart:28. Its handler (_capabilities, dispatcher.dart:86) enumerates the live registry with arg schemas — so the non-rotting design is already implemented server-side. +- A `/clide` skill is ALSO already present this session (other ongoing work), and it instructs agents to "Start with `clide capabilities` to enumerate the live tool surface." +- BUT: against the live instance, `clide capabilities` returns a usage stub at exit 0 (`usage: clide capabilities [args...]`) instead of the command table — i.e. the discoverability entry point the skill advertises is currently DEAD. Likely a stale running build or a C-client umbrella mismatch (native/clide-cli/clide.c forwards argv but the live server treats `capabilities` as a subsystem needing a verb). + +New scope for this ticket: +1. Verify/fix the live `clide capabilities` path end-to-end (rebuild/restart vs real wiring gap in the C client / server umbrella handling). Acceptance: `clide capabilities` returns the JSON command table against a running instance. +2. Reconcile with the already-shipped `/clide` skill rather than authoring anew — confirm it points at the working verb and covers the conventions. +Net: this is now a verify-and-reconcile task, not a build-from-scratch one. Related: T-249, T-208.', 'review', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:14:01', NULL, 'bca8ce0c246ffea147f7702c4cd0518d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-249', 'story', NULL, 'Image viewer conversation card + plumbing', 'Add an image-viewer card to the Claude conversation log, with the CLI verb that drives it (D-6 parity). + +Scope: +- Conversation card: render an image inline in the Claude conversation widget — clide-owned rendering (no opinionated package), display-only per D-78 (no inline interactive controls; any controls belong in the interaction zone). Respect theme tokens / ui-design. +- Plumbing: a `clide` verb to show an image in the log (e.g. `clide image show ` or via the pane subsystem), accepting a workspace path; define accepted formats (PNG/JPEG/...), path resolution (workspace-relative), and sizing/scaling behavior. +- Parity (D-6): the verb is the CLI counterpart of the card; ensure it registers in the dispatcher so it shows up in the discovery verb from T-248. + +Dependency: pairs with T-248 — without the /clide skill + discovery verb, Claude won''t know this card/verb exists even once shipped. Build the plumbing here; T-248 makes it discoverable. + +Refs: D-6 (parity), D-78 (interaction zone, display-only conversation widgets). Related: T-248.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 08:21:40', NULL, '7280f99bd28193b894588954367eb367', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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/CHANGELOG.md b/CHANGELOG.md index 7bb968f9..4dd353bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Image cards in the Claude conversation log: `clide image show + [--caption …]` renders an image inline (PNG/JPG/JPEG/GIF/WebP/BMP), + clide-owned and display-only (D-78). The path is resolved workspace-relative + and must exist; the verb registers in the dispatcher so it shows up in `clide + capabilities`. (T-249) - `clide capabilities` lists the live command surface as JSON (every verb with its subsystem + argument schema), reflected from the dispatcher so it never drifts. A new `/clide` skill points Claude at it for discovery. (T-248) diff --git a/lib/builtin/claude/src/activity_cluster.dart b/lib/builtin/claude/src/activity_cluster.dart index dedf181e..39d5b167 100644 --- a/lib/builtin/claude/src/activity_cluster.dart +++ b/lib/builtin/claude/src/activity_cluster.dart @@ -88,9 +88,11 @@ List groupConversation(List items, FoldLevel leve bool _isFoldable(ConversationItem item, FoldLevel level, Map 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(): diff --git a/lib/builtin/claude/src/conversation_controller.dart b/lib/builtin/claude/src/conversation_controller.dart index 2bb35641..da700189 100644 --- a/lib/builtin/claude/src/conversation_controller.dart +++ b/lib/builtin/claude/src/conversation_controller.dart @@ -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) { diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 6177f3bb..f9b0c377 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -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'; } } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index a182b0a5..2296b434 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -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 ` (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 diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index e8192457..49149f68 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -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 ` (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 // --------------------------------------------------------------------------- diff --git a/lib/main.dart b/lib/main.dart index 97fe97d0..78ec26f6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,13 +28,14 @@ import 'package:clide/builtin/vim/vim.dart'; import 'package:clide/builtin/tickets/tickets.dart'; import 'package:clide/builtin/todos/todos.dart'; import 'package:clide/builtin/welcome/welcome.dart'; -import 'dart:io' show Directory, Platform; +import 'dart:io' show Directory, File, Platform; import 'package:clide/kernel/kernel.dart'; import 'package:clide/src/daemon/dispatcher.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'; +import 'package:clide/src/daemon/image_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/status_command.dart'; import 'package:clide/src/daemon/ui_command.dart'; @@ -215,6 +216,18 @@ Future main() async { // (T-231, drive-half of D-6). Publishes a 'selection' to the kernel // MessageBus, captured post-boot; null in headless contexts. registerUiCommands(dispatcher, () => kernelMessages?.publish); + // `clide image show ` — drive an image card into the Claude + // conversation log (T-249, drive-half of D-6). Resolves the path + // (workspace-relative → absolute, must exist) here where workRoot is in + // scope, then publishes an 'image' message the Claude extension injects. + registerImageCommands( + dispatcher, + () => kernelMessages?.publish, + resolve: (path) { + final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); + return file.existsSync() ? file.absolute.path : null; + }, + ); // `clide status` — one-shot orientation snapshot (T-221): active pane, // focused file + selection, git summary, layout. Assembled here where the // live kernel + subsystem state is in scope; the reader's viewed doc is diff --git a/lib/src/daemon/image_commands.dart b/lib/src/daemon/image_commands.dart new file mode 100644 index 00000000..d43fd486 --- /dev/null +++ b/lib/src/daemon/image_commands.dart @@ -0,0 +1,123 @@ +/// Registers `image.show` — drive an image card into the Claude conversation +/// log from the CLI (T-249, D-6 parity). +/// +/// clide image show docs/wireframes/hud.png +/// clide image show /abs/path/shot.jpg --caption "before the fix" +/// +/// The card itself is clide-owned rendering in the conversation view; this is +/// its CLI counterpart. Like `ui.open` / `ui.toast`, the handler is decoupled +/// from the live UI: it validates the request, resolves the path to a real +/// on-disk file via an injected [ImagePathResolver], then publishes an `image` +/// message on the kernel MessageBus (captured post-boot in main.dart). A +/// consumer in the Claude extension injects the matching [ImageMessage] into +/// the primary session's conversation. Flutter-free so it runs under +/// `dart test`. +library; + +import '../ipc/command_schema.dart'; +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import 'dispatcher.dart'; +import 'ui_command.dart' show MessagePublisher; + +/// Image formats `image.show` accepts, matched on the path's extension. Mirrors +/// the composer's attachment sniff so what you can paste in, you can show. +const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}; + +/// Resolves a user-supplied image path (absolute or workspace-relative) to an +/// absolute path to an existing file, or null when no such file exists. +/// Injected so this file stays Flutter-free and unit-testable without touching +/// the real filesystem; main.dart wires it to the workspace root + `File`. +typedef ImagePathResolver = String? Function(String path); + +/// The MessageBus channel `image.show` publishes on; the Claude extension +/// subscribes to the same literal to inject the card. Kept here next to the +/// publisher so both ends point at one name. +const imageShowChannel = 'image'; + +void registerImageCommands( + DaemonDispatcher d, + MessagePublisher? Function() publisher, { + ImagePathResolver? resolve, +}) { + d.register( + 'image.show', + (req) async => _show(req, publisher, resolve), + schema: const CommandSchema( + positional: ['path'], + args: { + 'path': ArgSpec(required: true, rejectLeadingDash: true), + 'caption': ArgSpec(), + }, + ), + ); +} + +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 _show( + IpcRequest req, + MessagePublisher? Function() publisherSource, + ImagePathResolver? resolve, +) async { + final path = req.args['path'] as String?; + if (path == null || path.trim().isEmpty) { + return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)'); + } + + final ext = _extensionOf(path); + if (!imageShowExtensions.contains(ext)) { + return _userErr( + req.id, + 'unsupported image format${ext.isEmpty ? '' : ' ".$ext"'}', + hint: 'one of: ${(imageShowExtensions.toList()..sort()).join(', ')}', + ); + } + + // Resolve to a concrete file before publishing, so the CLI fails honestly on + // a typo instead of silently showing a broken card. A null resolver (headless + // tests) passes the path through unverified. + String resolved = path; + if (resolve != null) { + final abs = resolve(path); + if (abs == null) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: 'no such image: $path', + hint: 'path is resolved relative to the workspace root', + ), + ); + } + resolved = abs; + } + + final caption = req.args['caption'] as String?; + + final publish = publisherSource(); + if (publish == null) { + // No live UI bus — headless / CLI-only context. Honest failure, not a hang. + 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', imageShowChannel, { + 'path': resolved, + if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(), + }); + return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'shown': true}); +} + +/// Lower-cased extension (without the dot) of [path], or '' if none. +String _extensionOf(String path) { + final slash = path.lastIndexOf('/'); + final dot = path.lastIndexOf('.'); + if (dot <= 0 || dot < slash || dot == path.length - 1) return ''; + return path.substring(dot + 1).toLowerCase(); +} diff --git a/test/builtin/claude/activity_cluster_test.dart b/test/builtin/claude/activity_cluster_test.dart index e4701e4a..4773d018 100644 --- a/test/builtin/claude/activity_cluster_test.dart +++ b/test/builtin/claude/activity_cluster_test.dart @@ -78,5 +78,13 @@ void main() { test('empty input yields no groups', () { expect(groupConversation(const [], FoldLevel.tools), isEmpty); }); + + test('an image card stays first-class even at L3 (everything)', () { + final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png'); + final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything); + // The tool + result fold; the image is sticky and seals the cluster. + expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem']); + expect((groups[1] as StickyItem).item, isA()); + }); }); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index dad91078..ead7d8a2 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -15,6 +15,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show Image, FileImage; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; @@ -29,6 +30,7 @@ AssistantToolUse _tool(String name, Map input) => AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input); ToolResultMessage _result(String content, {bool isError = false}) => ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError); +ImageMessage _image(String path, {String? caption}) => ImageMessage(uuid: 'i', timestamp: _t, isSidechain: false, path: path, caption: caption); class _MockClipboard { Map _data = {'text': null}; @@ -187,6 +189,23 @@ void main() { expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget); }); + testWidgets('an image card renders with the "image" label, the file, and its caption (T-249)', (tester) async { + await pumpWith(tester, [_image('/no/such/file.png', caption: 'before the fix')]); + expect(find.text('image'), findsOneWidget); + expect(find.text('before the fix'), findsOneWidget); + // The image is wired to the resolved file path (display-only, D-78). + final img = tester.widget(find.byType(Image)); + expect((img.image as FileImage).file.path, '/no/such/file.png'); + }); + + testWidgets('inject() drives a new image card into a live view (T-249)', (tester) async { + final c = await pumpWith(tester, [_user('hi')]); + expect(find.text('image'), findsNothing); + c.inject(_image('/no/such/shot.png')); + await tester.pumpAndSettle(); + expect(find.text('image'), findsOneWidget); + }); + testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async { await pumpWith( tester, diff --git a/test/daemon/image_commands_test.dart b/test/daemon/image_commands_test.dart new file mode 100644 index 00000000..65ca8aa1 --- /dev/null +++ b/test/daemon/image_commands_test.dart @@ -0,0 +1,120 @@ +/// Tests for `image.show` — the CLI that drives an image card into the Claude +/// conversation log (T-249, D-6 parity). Verifies format validation, path +/// resolution, the published MessageBus 'image' payload, and honest failure +/// when the file is missing or there is no live UI. +library; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/image_commands.dart'; +import 'package:test/test.dart'; + +void main() { + late List<({String publisher, String channel, Map data})> published; + late DaemonDispatcher d; + + // [found] is the set of paths the fake resolver treats as existing on disk; + // it echoes them back prefixed with /abs to stand in for an absolute path. + void wire({bool liveUi = true, Set found = const {'docs/diagram.png'}, bool withResolver = true}) { + published = []; + d = DaemonDispatcher(); + registerImageCommands( + d, + () { + if (!liveUi) return null; + return (publisher, channel, data) => published.add((publisher: publisher, channel: channel, data: data)); + }, + resolve: withResolver ? (path) => found.contains(path) ? '/abs/$path' : null : null, + ); + } + + Future show(List positional, {Map? flags}) => d.dispatch( + IpcRequest(id: '1', cmd: 'image.show', args: {'positional': positional, if (flags != null) 'flags': flags}), + ); + + test('resolves a workspace-relative path and publishes an image message', () async { + wire(); + final r = await show(['docs/diagram.png']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['shown'], isTrue); + expect(r.data['path'], '/abs/docs/diagram.png'); + expect(published.single.publisher, 'cli'); + expect(published.single.channel, 'image'); + expect(published.single.data, {'path': '/abs/docs/diagram.png'}); + }); + + test('--caption rides along in the payload', () async { + wire(); + final r = await show(['docs/diagram.png'], flags: {'caption': 'before the fix'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data, {'path': '/abs/docs/diagram.png', 'caption': 'before the fix'}); + }); + + test('accepts the documented formats case-insensitively', () async { + for (final name in ['a.PNG', 'b.jpg', 'c.jpeg', 'd.gif', 'e.webp', 'f.bmp']) { + wire(found: {name}); + final r = await show([name]); + expect(r.ok, isTrue, reason: '$name: ${r.error?.message}'); + } + }); + + test('unsupported format → userError, nothing published', () async { + wire(found: {'notes.txt'}); + final r = await show(['notes.txt']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('missing path → userError', () async { + wire(); + final r = await show([]); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('a file that does not resolve → notFound, nothing published', () async { + wire(found: const {}); + final r = await show(['docs/diagram.png']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.notFound); + expect(published, isEmpty); + }); + + test('no live UI (null publisher) → toolError, not a hang', () async { + wire(liveUi: false); + final r = await show(['docs/diagram.png']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.toolError); + }); + + test('a leading-dash path is rejected by the schema (argv-injection guard)', () async { + wire(); + final r = await show(['-rf.png']); + expect(r.ok, isFalse); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('null resolver passes the path through unverified (headless)', () async { + wire(withResolver: false); + final r = await show(['docs/diagram.png']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data, {'path': 'docs/diagram.png'}); + }); + + test('image.show appears in the capabilities discovery surface (T-248)', () async { + wire(); + final r = await d.dispatch(IpcRequest(id: '1', cmd: 'capabilities', args: const {})); + expect(r.ok, isTrue); + final commands = r.data['commands'] as Map; + expect(commands.containsKey('image.show'), isTrue); + final spec = commands['image.show'] as Map; + expect(spec['subsystem'], 'image'); + expect(spec['verb'], 'show'); + expect(spec['positional'], ['path']); + final args = spec['args'] as Map; + expect((args['path'] as Map)['required'], true); + expect(args.containsKey('caption'), isTrue); + }); +}