41 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Fable 5 0fcd1daaa0 release v2.10.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:52:35 +02:00
jpmschweitzerandClaude Fable 5 d762f8b19b chore(plan): sweep the T-512 idmap export
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:50:01 +02:00
jpmschweitzerandClaude Fable 5 6f148b9910 docs(skills): lead the clide skill description with the entry action (T-512)
FR-2: settled-reach's drifted copy of the skill had a sharper opening —
naming clide-on-PATH + CLIDE_SOCK and `clide capabilities` as the first
move — which de-forking back to canonical would have dropped. The
description is what an agent reads when deciding whether to invoke the
skill, so the entry action belongs up front. Frontmatter wording only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:49:23 +02:00
jpmschweitzerandClaude Fable 5 670915fbc9 chore(plan): close T-511 (per-workspace PATH preset shipped)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:49:33 +02:00
jpmschweitzerandClaude Fable 5 7b395c5a0a fix(testmode): skip the fd-inheritance probe when its helper is absent
The probe spawns a hand-built /tmp/checkfd binary that exists only on
machines where the T-438-era investigation ran; everywhere else it
failed the whole harness with a ProcessException. Absence is now an
explicit skip line, and the helper path is a named constant in both
the io and web-stub variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:48:48 +02:00
jpmschweitzerandClaude Fable 5 d9d06cf98e test(coverage): bring the backlinks panel under test; close preset gaps
The pre-push coverage gate was failing at 94.84% — below the 95% floor
even before the T-511 feature landed (the feature files sit at 97%; the
tree without them was at 94.80%). The bulk of the debt was the pql
backlinks panel: controller and view carried ~100 lines with zero
tests. Covers controller fetch/error/event-refresh/dispose and view
empty/error/group/row-navigation states, plus the T-511 stragglers a
formatter-conflicted edit dropped (presetLookupRoot) and the
env.path production default seams. Gate now passes at 95.26%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:45:10 +02:00
jpmschweitzerandClaude Fable 5 f5c4826a55 docs(governance): fix the last D-58 -> D-92 reference in D-104
The CLIDE_PQL_BIN override is defined in D-92 (ship pql bundled);
D-58 is format engines. Third of three misattributions on this record
— the first two were corrected alongside D-106.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:18:08 +02:00
jpmschweitzerandClaude Fable 5 5398ecdc3f test(files): lock .worktrees/ into the ignore roster + init scaffold
The 23dd7fd1 behavior shipped without a test delta; the builtin-roster
lock test and the scaffold assertion now cover it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:17:51 +02:00
jpmschweitzerandClaude Fable 5 0a44f95dfd fix(env): harden the PATH preset per review (T-511)
Three holes from the T-511 adversarial review pass:

- An entry containing the PATH separator smuggled extra tokens into
  the joined PATH — a stray trailing ':' yields an EMPTY token, which
  POSIX shells resolve as CWD (the dot-in-PATH hazard). The CLI verb
  and the settings control now reject such entries, and applyPathPreset
  skips malformed stored values that predate the check.
- The gitdir pointer a worktree resolution follows is repo-controlled
  text; the resolved main root is now validated (must hold a real
  .git directory) before its preset key is trusted, so a crafted
  pointer can't alias an arbitrary path's preset.
- A pane spawned with a cwd below the workspace root hashed the
  subdirectory and silently missed the workspace preset; the lookup
  now keys any in-workspace cwd to the workspace root
  (presetLookupRoot).

Also: the Add button pairs buttonBackground with its own
buttonHoverBackground token instead of borrowing the list-item hover
token, and the hosted-Claude leg gains an end-to-end orchestrator test
(preset lookup → spawn env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:17:35 +02:00
jpmschweitzerandClaude Fable 5 49c82556a2 fix(settings): quote numeric-shaped YAML keys so hash-keyed settings survive reload
The emitter wrote block-map keys raw while only values went through
quoting. A workspace-hash key segment that happens to be all digits
with a leading zero (or digits-e-digits) reloaded as an int/float —
leading zero dropped, or collapsed to Infinity — orphaning the stored
value. Hit ~1 in 1200 repos, deterministically and permanently: the
PATH preset (D-106) and the pre-existing Claude account binding for
such a repo vanished on every restart with no diagnostics. Same defect
class as the T-376 value-corruption fixes in this file; found by the
T-511 review pass, confirmed against the live store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:17:15 +02:00
jpmschweitzerandClaude Fable 5 880f642261 docs(inbox): add the feature-request inbox; triage FR-1 into D-106 (T-511)
The pre-triage inbox logged from consumer sessions (pql,
settled-reach), mirroring pql's own convention. FR-1 (per-repo PATH
preset) is accepted and shipped — tombstoned here, decided in D-106,
implemented under T-511. FR-2 (clide skill description lead) remains
proposed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:41:47 +02:00
jpmschweitzerandClaude Fable 5 23dd7fd187 feat(files): treat in-repo .worktrees/ checkouts as ignored (T-511)
D-106 supports opening a worktree under <repo>/.worktrees/ as its own
workspace; the dir itself is sibling checkouts, not tree content — so
the builtin ignore set hides it and the project-init scaffold
gitignores it alongside .clide/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:41:24 +02:00
jpmschweitzerandClaude Fable 5 1308cb3d23 feat(settings): Workspace PATH section in the Tools category (T-511)
The UI half of D-106 (D-6 parity with `clide env path`): an ordered
preset editor — add/remove/reorder, a missing-dir warning, a worktree
note naming the shared main repo, and capture-from-login-shell
suggestions adopted with one click. Writes land on the same user-scope
key the CLI verbs use and publish on the same channel, so both
surfaces stay live off the store notifier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:41:06 +02:00
jpmschweitzerandClaude Fable 5 e1fea83868 feat(env): per-workspace PATH preset injected at spawn (T-511)
Implements D-106. The T-439 login-shell probe is a global heuristic
with a known hole — login-but-non-interactive shells skip ~/.bashrc,
so interactive-only additions (brew shellenv) never reach the agent's
Bash tool or terminal panes on a desktop launch. The preset is the
explicit per-repo layer on top: user-scope storage keyed by repo
identity (a linked worktree resolves through its gitdir pointer to the
main repo, so worktrees share the preset), prepended at spawn via the
PaneRegistry pathForSpawn hook and agentEnvDelta prependDirs — which
now exports PATH even when clide is already resolvable, closing the
gap where the hosted session inherited the sparse GUI PATH untouched.

CLI half: `clide env path list|set|add|remove|clear|capture` over an
injected Flutter-free store port; capture diffs the login-shell PATH
against the process PATH to suggest the dirs a desktop launch dropped.
Binary resolution (toolchain, supporter pins, bundled pql/git) stays
preset-blind per the D-92/T-98 fence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:40:48 +02:00
jpmschweitzerandClaude Fable 5 1ac60d1fe7 docs(governance): D-106 per-workspace PATH preset (T-511)
Decides FR-1: a machine-local, user-scope list of dirs prepended to the
PATH of every spawned shell, keyed by repo identity so linked worktrees
(.worktrees/) share their main repo's preset. Prepend-over-login-shell
semantics, D-6 CLI parity (env path verbs), and a security fence: the
preset never feeds clide's own binary resolution (T-98 lesson).

Also corrects D-104's cross-references: the bundled-pql decision is
D-92, not D-58 (D-58 is format engines) — the anchor was dead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:40:28 +02:00
jpmschweitzerandClaude Fable 5 bd3acf38e9 chore(plan): close T-510 (bypass reachability shipped)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:59:08 +02:00
jpmschweitzerandClaude Fable 5 a15b3fd098 feat(claude): reach bypass via Ctrl/Cmd+Shift+M + shift-click (T-510)
Restores the original intent that T-226's refinement wording lost: the
shift modifier is the explicit opt-in for bypassPermissions, not a
separate confirm flow. Plain Ctrl/Cmd+M keeps cycling the safe trio;
Ctrl/Cmd+Shift+M cycles the full list. The composer menu's bypass row
was permanently disabled, deferring to "the cockpit's confirmed path" —
but that roster is ghost-fed (T-396), so bypass was unreachable from
the primary session's UI entirely. The row now no-ops on a plain click
(menu stays open) and selects on shift-click, with a hint naming the
gesture. Roster badge and /permissions paths unchanged.

lib/test_app.dart is a format-only follow-up to the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:54:11 +02:00
jpmschweitzerandClaude Fable 5 0f1d15efdd test(testmode): add canvas to the extension roster (T-322)
builtin.canvas graduated from a Tier-0 stub to a real extension with an
activate/deactivate lifecycle, so the real-boot harness should exercise
it like its siblings. 26/26 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:03:54 +02:00
jpmschweitzerandClaude Fable 5 e2e305dea6 feat(canvas): open .canvas files in a workspace pane (T-322)
Makes the canvas foundation (parser/renderer/view) reachable. The
extension owns an app-scoped MultitabController (the diff/T-233
pattern) so open documents survive the pane being rebuilt; each
document is a real sub-tab per the refinement decision, kept alive
across switches. Routing goes through the existing seams instead of
the dead TabContribution.fileGlobs field: openWorkspaceFile gains a
.canvas branch mirroring .md, and ui.open gains a canvas reader for
D-6 parity (clide ui open canvas <path>). Also corrects the
json_canvas doc header that claimed SVG-lowering — the interactive
pane paints the model directly (D-103 live-widget exception).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:56:09 +02:00
jpmschweitzerandClaude Fable 5 8d7d886bea chore(plan): record T-322 integration-phase refinement (T-322)
Batch selection via /whats-next confirmed finishing T-322 as the next
work. Refinement pass resolved the open integration questions:
workspace slot + real MultitabPane tabs (user-confirmed), file-open
routing via the openWorkspaceFile() .md-branch mirror plus a ui_command
_readers entry for D-6 CLI parity (fileGlobs confirmed dead — not
wired), and a stale SVG-lowering doc header in json_canvas.dart to fix
alongside the pane-shell work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:26:01 +02:00
jpmschweitzerandClaude Opus 4.8 53c053d4d4 chore(plan): record T-322 canvas foundation complete (parser/renderer/view)
The .canvas rendering + interaction foundation is built and tested (parser,
CustomPaint renderer, interactive CanvasView — 21 tests). Records the
remaining reachable-integration phase: pane shell, CanvasExtension
registration, and .canvas file-open routing (TabContribution.fileGlobs is
declared but unconsumed, so routing must be built).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:23:14 +02:00
jpmschweitzerandClaude Opus 4.8 7700fc7a83 feat(canvas): interactive CanvasView — pan, zoom, click-select (T-322)
Adds hitTestCanvasNode (topmost node under a point, cards over the group
frames behind them, via the same viewport the painter draws with) and the
CanvasView widget: scroll-zoom, drag-pan, and click-to-select with a focus
ring. Node drag/resize and edit affordances follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:14:13 +02:00
jpmschweitzerandClaude Opus 4.8 d42c8c3236 chore(plan): keep T-509 broad — render bug is one symptom
Records that the model/effort trust problem spans more than the
local-command-stdout render defect: control state reflection, point-of-
interaction acknowledgement, and active-value drift are all in scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:41:31 +02:00
jpmschweitzerandClaude Opus 4.8 717b854d19 chore(plan): file T-509 (model/effort feedback) + T-322 canvas progress
T-509 (child of the UI/UX epic T-276): investigate + improve the model
switch and effort toggle so a change is unmistakably confirmed. T-322
(canvas pane) moved to in_progress with its parser + renderer foundations
recorded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:39:16 +02:00
jpmschweitzerandClaude Opus 4.8 7fa2d091bc chore(plan): file T-509 (model/effort feedback) + T-322 canvas progress
T-509 (child of the UI/UX epic T-276): investigate + improve the model
switch and effort toggle so a change is unmistakably confirmed. T-322
(canvas pane) moved to in_progress with its parser + renderer foundations
recorded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:37:26 +02:00
jpmschweitzerandClaude Opus 4.8 035a37a13c feat(canvas): CustomPaint renderer for .canvas docs (T-322)
Paints a CanvasDoc — group frames behind, edges with arrowheads, then
node cards (text / file / link) — fitted by a shared pan/zoom CanvasViewport
so a later hit-test lands on what's drawn. Node colours come from the file
(Obsidian presets 1..6 or hex), not clide tokens, since a .canvas is
arbitrary content; only the chrome is themed. Read-only for now; selection,
drag, and edit affordances follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:26:07 +02:00
jpmschweitzerandClaude Opus 4.8 2eb5d0f170 feat(canvas): JSONCanvas parser + model for .canvas files (T-322)
Parses the Obsidian .canvas format into a typed CanvasDoc — text / file /
link / group nodes and edges (sides, end caps, colour, label) — and
serialises back with round-trip fidelity. Unknown node types and entries
missing required fields are skipped rather than fatal; end caps omit their
spec defaults on write. Pure Dart, no I/O; runs under dart test. The
foundation for the interactive canvas pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:19:14 +02:00
jpmschweitzerandClaude Opus 4.8 3e972a7309 chore(plan): close T-323 — vault graph view complete
The Tier-5 graph pane is fully delivered: force-directed layout, hover
highlight, click-to-open, pan/zoom, and path-glob / tag / depth filters.
Real-boot verified via testmode; 56 graph tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:48:20 +02:00
jpmschweitzerandClaude Opus 4.8 aabec87d9a feat(graph): filter bar — path glob, tag include/exclude, depth (T-323)
Switches the controller from pql.outlinks to pql.meta (outlinks + tags in
one call per file) and strips #heading fragments from link targets, so a
heading link now connects the two notes. Adds a filter bar above the graph:
a path glob that re-queries pql on submit, a depth-from-active selector for
the local graph around the open note, and tri-state tag pills
(neutral / include / exclude). The pane draws the filtered visibleGraph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:42:50 +02:00
jpmschweitzerandClaude Opus 4.8 f859173b98 feat(graph): filter model — local-graph BFS, subgraph, GraphFilter (T-323)
Adds the pure filtering primitives the graph pane composes: VaultGraph
nodesWithin (depth-bounded BFS over undirected edges = the local graph
around a note) and subgraph (retain a node set + the edges between them),
plus GraphFilter, which combines depth-from-active with tag include/exclude
over a caller-supplied tag map. Flutter-free; runs under dart test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:26:59 +02:00
jpmschweitzerandClaude Opus 4.8 46d9b4031c test(testmode): cover builtin.graph activation in the harness
Adds PqlExtension + GraphExtension to the extensions-category boot test so
the graph view's dependency-ordered activation and real i18n-catalog load
are validated in a full app boot, not just the widget fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:40:10 +02:00
jpmschweitzerandClaude Opus 4.8 e6556401f3 feat(graph): pan + zoom on the vault graph view (T-323)
Folds a user zoom (scroll wheel, clamped) and pan (drag) transform into
GraphViewport so the painter and hit-testing move in lockstep — hover and
click keep landing on what's drawn. Zoom scales about the canvas centre; a
fresh graph re-fits and drops the transform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:55:59 +02:00
jpmschweitzerandClaude 07ef8cbbcc chore(plan): close T-507 — clide-side skill cleanup done
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 11:56:04 +02:00
jpmschweitzerandClaude 78d5f22104 docs(skills): prune bundled skills to clide-specific set (T-507)
The 2026-07-02 skill review found the repo bundling skills that don't
belong to it: d2-diagram and frame0-wireframe are repo-agnostic and
carried settled-reach game content verbatim (agent personas, palette,
templates) presented as clide conventions; skill-create was a
byte-identical copy of a generic skill; clean-house was double-installed
(the pql-managed user-scope copy is canonical); the generic commit skill
collided with git-commit on its trigger word while contradicting three
repo safety rules; penpot-login was fully broken and published personal
infrastructure in a public repo.

d2-diagram, frame0-wireframe, and skill-create now live de-contaminated
at user scope (~/.claude/skills/). The commit/penpot-login/clean-house/
skill-create deletions landed with the previous commit (pre-staged by
git rm); this one removes the centralized pair and rewrites the README
index to the five clide-specific skills that remain.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 11:55:28 +02:00
jpmschweitzerandClaude b02740e87d docs(skills): fix stale guidance in bundled skills (T-507)
The 2026-07-02 skill review (adversarially verified) found the bundled
skills teaching pre-refactor reality: whats-next pointed refinement
agents at the pre-governance/ decision paths, claimed gh doesn't exist,
and taught the hand-staged .pql/changelog flow the pre-commit hook
replaced; git-commit mandated the heredoc-in-substitution commit form
the repo moved away from and referenced sidecar-era paths; the clide
skill omitted the dock slot (D-87); testmode omitted the terminal
category; ui-design's glyph table taught the static-const icon API
removed in T-314, and its generator scanned for a pattern that no
longer matches anything.

All fixed against current repo state. The glyph table is regenerated
3-column (name-keyed, byName-first); tool/gen_phosphor_glyphs.dart
round-trips it byte-identically. git-commit also gains the
session-vs-pre-existing triage step (backported from the settled-reach
variant) since this worktree hosts concurrent Claude sessions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 11:51:41 +02:00
jpmschweitzerandClaude Opus 4.8 a5acb6acc7 feat(graph): register the vault graph view in the context panel (T-323)
Wires GraphPanel into GraphExtension as the graph.view context-panel tab
(its icon-rail entry and tab id were already scaffolded) and ships the
builtin.graph i18n catalogs. The graph is now reachable: notes as nodes,
wikilinks as edges, hover-highlight and click-to-open. It activates after
its pql dependency, which supplies the link data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:32:13 +02:00
jpmschweitzerandClaude Opus 4.8 6fae1bc46a feat(graph): graph context-panel shell — states + click-to-open (T-323)
Wraps GraphController + GraphView into the panel widget: loads the vault
graph on mount, shows a spinner / empty / error state until it is ready,
then draws the graph and opens a note on node click via editor.open. A
debounced refresh keeps the current graph on screen instead of flashing
back to the spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:43:57 +02:00
jpmschweitzerandClaude Opus 4.8 3b67725b7e feat(graph): pql-backed GraphController — files + outlinks to VaultGraph (T-323)
Lists every markdown note (nodes) and fetches each one's outlinks
(edges) via the pql.files / pql.outlinks IPC commands, assembling the
file->outlinks map that VaultGraph consumes. Coalesces file-change
bursts into one debounced reload. 1+N calls, since pql exposes no
bulk-outlinks query; acceptable for a spinner-backed, explicitly-opened
view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:30:35 +02:00
jpmschweitzerandClaude Opus 4.8 c529703b9f feat(graph): interactive GraphView — hover-highlight + click-to-open (T-323)
GraphView lays a VaultGraph out with the force solver, paints it, and wires
hover (light the hovered node's neighbourhood, dim the rest) + click (onOpen
with the node's vault path). A shared GraphViewport keeps hit-testing aligned
with paint. Solver + model + painter + interactive view now stand; the pql
link-data wiring, filter, and MultitabPane/slot registration are the
remaining "integrate into the app" work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:34:26 +02:00
jpmschweitzerandClaude Opus 4.8 6bea1723bb feat(graph): CustomPaint renderer for the vault graph (T-323)
GraphPainter draws a solver-positioned VaultGraph — edges as lines, nodes as
labelled dots — fitting the layout space into the canvas (aspect-preserving,
centered), with a hovered node's neighbourhood lit and the rest dimmed.
Paints through SurfaceTokens (the graph is IDE chrome here, D-7).
Pixel-tested. Solver + model + painter now stand; the interactive pane (pql
wiring, hover/click, filter, tab/slot) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:31:37 +02:00
jpmschweitzerandClaude Opus 4.8 084bf84258 feat(graph): vault link-graph model — notes to nodes, wikilinks to edges (T-323)
VaultGraph.fromOutlinks builds the graph from pql's per-file outlinks: every
file a node (labelled by basename), each outlink to a known file an edge;
self/dangling links dropped, parallels de-duped. neighborhood() drives the
hover-highlight; edgePairs feeds the ForceLayout solver. Pure Dart,
dart-tested (6 cases). The graph pane's data layer, on top of the solver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:32:37 +02:00
104 changed files with 6831 additions and 5258 deletions
+13 -10
View File
@@ -5,19 +5,22 @@ lives in its own directory with a `SKILL.md` (frontmatter + body) and any
helper scripts. This index is for humans skimming what's available; Claude
discovers skills automatically from the directory structure.
Only clide-specific skills live in this repo. Repo-agnostic skills
(`d2-diagram`, `frame0-wireframe`, `skill-create`) live at user scope
(`~/.claude/skills/`), as do the pql-distributed ones (`pql`, `clean-house`,
installed via `pql init`).
| Skill | Purpose |
|---|---|
| [`d2-diagram`](d2-diagram/SKILL.md) | Generate technical diagrams from `.d2` source with the d2 CLI; renders to PNG. |
| [`frame0-wireframe`](frame0-wireframe/SKILL.md) | Author UI wireframes as local JSON and sync to the Frame0 desktop app for rendering + export. |
| [`git-commit`](git-commit/SKILL.md) | Commit conventions for this repo — message style, CHANGELOG discipline (40/60 word cap), attribution trailer, safety rules. |
| [`pql`](pql/SKILL.md) | Query and plan against the markdown vault via the `pql` CLI (decisions, tickets, structural queries). |
| [`skill-create`](skill-create/SKILL.md) | Guidance for creating new skills — SKILL.md structure, bundling scripts, packaging. |
| [`testmode`](testmode/SKILL.md) | Run and interpret the `ClideTestApp` platform integration harness; smoke-test after toolchain / IPC / theme / native changes. |
| [`ui-design`](ui-design/SKILL.md) | Visual design guide — surface tokens, control geometry, Phosphor icons. |
| [`clide`](clide/SKILL.md) | Observe/drive the live clide UI through the `clide` CLI; discover the surface with `clide capabilities`. |
| [`git-commit`](git-commit/SKILL.md) | Commit conventions for this repo — message style, CHANGELOG discipline (60-word cap), attribution trailer, safety rules. |
| [`testmode`](testmode/SKILL.md) | Run and interpret the `ClideTestApp` platform integration harness; smoke-test after toolchain / IPC / PTY / theme / native changes. |
| [`ui-design`](ui-design/SKILL.md) | Visual design guide — surface tokens, control geometry, Phosphor icons (`byName` + generated glyph table). |
| [`whats-next`](whats-next/SKILL.md) | Dependency-driven batch selection against the pql backlog. Walks the initiative/epic tree, filters unblocked work, refines, optionally activates. |
## Adding a skill
Use the `skill-create` skill (or follow its SKILL.md by hand). Add a row to
the table above so the inventory stays accurate; the index is otherwise just
a directory listing.
Use the `skill-create` skill (user scope; or follow its SKILL.md by hand).
Clide-specific skills go here; repo-agnostic ones go to `~/.claude/skills/`.
Add a row to the table above so the inventory stays accurate; the index is
otherwise just a directory listing.
@@ -1,5 +0,0 @@
{
"version": "1.5.0",
"hash": "sha256:8090b99552c828ce019aa3740d8a0acb4768a443704b4e935efa23b32e91128d",
"installed_at": "2026-06-02T08:46:25Z"
}
-229
View File
@@ -1,229 +0,0 @@
---
name: clean-house
description: >
Run a documentation-discipline pass over a DQR (Decisions / Questions /
Rejected) markdown system. Audits decisions/, surfaces drift (broken
anchor links, missing Q→D backlinks, sunset-shaped phrases without
linked tickets, files over the split threshold, dead cross-references),
batches findings by category, and uses AskUserQuestion to apply fixes
interactively. Trigger this skill whenever the user asks to clean,
tidy, audit, sweep, garden, or review the state of decisions/, the
DQR system, the questions index, or the records under decisions/.
Also trigger before a release, after a session that touched many
records, or whenever the user mentions documentation drift, stale
plans, or "house" / "clean-house" / "house cleaning". Imperative —
apply fixes, not just report.
---
# clean-house
A periodic gardening pass over `decisions/`. The DQR system is
markdown-as-source-of-truth; `.pql/pql.db` is a derived index. This skill
operates on the markdown files, batches drift by category, and uses
`AskUserQuestion` to decide what to apply.
The skill is **imperative**. It really cleans. The verb-noun match is
load-bearing — a `clean-house` that only produced a report would be
misnamed. Mechanical fixes get a single batched approval, judgment-call
findings get individual prompts, and a "stop asking" escape hatch is
always present.
You — Claude — are the runtime. Read this file, read
`references/rules.md`, follow the procedure below, call `pql` and `Edit`
to do the work, call `AskUserQuestion` for the prompts.
## When to run
- Before a release.
- After a session that touched many D / Q / R records.
- When something feels stale and a sweep is wanted.
- When `pql decisions validate` is green but `decisions/` still feels off.
This skill is **not** a replacement for `pql decisions validate`.
Validate is fast, hot, and gates pre-push. clean-house is slow, cold,
on-demand. Escalation path: rules that fire often here are candidates
for promotion to `pql decisions validate`; rules that almost never fire
stay here where false positives don't slow anyone down.
## Procedure
1. **Gate on validate.** Run `pql decisions validate`. If it exits
non-zero, stop and tell the user to fix validator findings first.
Do not proceed.
2. **Load the rule catalog.** Read `references/rules.md`. Each rule
names its detection, fix, and finding-id format.
3. **Probe conventions.** Read `decisions/.clean-house.yaml` if it
exists; otherwise infer from the project. Conventions to resolve:
- `heading_level`: 2 or 3 — the depth records use (`## D-N` vs
`### D-N`). Probe by sampling the first 3 records' file
locations and looking for the first matching ATX heading.
- `backlink_phrasing`: the project's Q→D backlink phrase (default
`Resolved → D-N`; some projects use `Resolved as D-N` or
`→ D-N`).
- `file_threshold`: default 350 (RULE-FILE-OVER-THRESHOLD).
- `stale_open_q_days`: default 60 (RULE-STALE-OPEN-Q).
Pass the resolved conventions into each rule's detection. Cache
the probe result for the run.
`decisions/.clean-house.yaml` shape (all keys optional):
```yaml
heading_level: 3
backlink_phrasing: "Resolved → "
file_threshold: 500
stale_open_q_days: 90
exclude_paths: ["legacy/", "drafts/"]
```
4. **Sync the index.** Run `pql decisions sync` so the DB reflects
the markdown.
5. **Walk decisions/.** Use `pql decisions list` to enumerate
records, plus `pql decisions read <id>` per record when a rule
needs the body. For file-level rules (size, sort), read the
markdown files directly. **Filter out** any record whose
`file_path` begins with `legacy/` (or any prefix in
`exclude_paths` from the probe).
6. **Run each rule's detection.** Collect findings. Tag each with
its rule ID, finding ID (per the rule's format), category
(`mechanical` | `judgment`), and the record(s)/file(s) involved.
7. **Group findings by rule** and prompt. Honor any "skipped 3+
times" findings from the skip ledger by escalating their prompt
("skipped 3x — promote to manual decision, downgrade to
summary-only, or keep asking?"):
- **Mechanical rules** — one prompt per rule, batched. Show the
count and offer: apply all / show diff first / skip this rule /
**stop asking** (global — see below).
- **Judgment rules** — one prompt per finding. Options match the
rule's `Fix` section, plus the same global **stop asking**.
**"Stop asking" semantics (global).** When the user picks "stop
asking" on any prompt, immediately halt all further prompts in
the run — mechanical batches not yet asked AND remaining
judgment findings. Move all unprompted findings to the skip
ledger marked `deferred-stop-asking`. Skip to step 9.
**"Show diff first" loop.** When the user picks "show diff
first" on a mechanical batch, emit a unified diff of the staged
edits (one block per file), then re-prompt the same question
with the same options minus "show diff first" — preventing
infinite loops while still letting the user inspect before
committing.
8. **Apply approvals.** For mechanical rules use `Edit` to mutate
the markdown directly. For judgment rules, follow the option the
user picked (file a ticket via `pql ticket new`, mark superseded
by editing the record, etc.).
9. **Update the skip ledger and history.** Both files live at
`decisions/.clean-house-state.md`, gitignored. The file uses
per-run section headers so it doubles as run history:
```markdown
# clean-house run history
## 2026-05-06T13:42Z
fired: RULE-ANCHOR-DRIFT(1) RULE-SUNSET-WITHOUT-TICKET(1)
applied: RULE-ANCHOR-DRIFT(1) RULE-SUNSET-WITHOUT-TICKET(1)
skipped:
- RULE-ANCHOR-DRIFT D-8:#q-1-markdown-mirror-for-tickets
## 2026-05-04T11:10Z
fired: ...
```
Each section starts with a UTC ISO-8601 timestamp. `fired`
counts findings detected; `applied` counts findings the user
acted on; `skipped` lists `<rule-id> <finding-id>` pairs.
**Promotion-candidate computation.** Scan the most recent N=7
sections (or all sections if fewer). A rule is a promotion
candidate if it `fired` in ≥ 5 of those sections. Surface the
candidates in the summary; do not auto-promote.
10. **Emit the summary** (see Output below).
## Question phrasing
Always include the rule ID in the prompt so the user learns what's
being checked.
**Mechanical (batched):**
> RULE-ANCHOR-DRIFT: 12 cross-reference anchors point to headings that
> no longer exist (renamed or deleted). Apply all 12 fixes? Options:
> apply all / show diff first / skip this rule / stop asking, summarize
> the rest.
**Judgment (individual):**
> RULE-SUNSET-WITHOUT-TICKET: D-59 mentions "must track dugite-native
> releases for security updates" but has no linked T. Options: file T
> now / draft a T description first / mark as already covered (add note
> to D) / skip / stop asking.
Phrasing rules:
- **Always include "stop asking, summarize the rest."** Sessions
without bandwidth for full review need an escape. Forcing answers
to every question kills the tool.
- **Always include "skip."** Skipped findings go to the ledger; three
consecutive skips of the same finding promote the next prompt.
- **Mechanical batches; judgment doesn't.** "Apply all 12 fixes?" is
one decision against a clear delta. Twelve individual prompts defeat
the point. Conversely, judgment findings are each separate work —
presenting them as a batch hides cost.
## What this skill does NOT do
- Does not file tickets without asking.
- Does not rewrite D-record body prose, only metadata fields and
cross-reference links.
- Does not touch records under `legacy/`.
- Does not run `pql decisions validate` *as a fix* — only as a
precondition gate.
- Does not promote rules to `pql decisions validate` automatically.
Promotion is a human decision; this skill only flags candidates
("RULE-X has fired in 5 of the last 7 runs — consider promoting").
## Non-interactive context
If invoked without an interactive `AskUserQuestion` surface (a CI run,
a batch script), refuse to apply judgment fixes. Apply mechanical
fixes only if the user explicitly says so via the trigger phrase
("auto-apply mechanical, skip judgment"); otherwise emit the summary
and exit without mutating files.
## Output
At the end, emit a summary block:
```
clean-house — sweep complete
────────────────────────────
Files scanned: 11
Records parsed: 62 D, 23 Q, 11 R
Conventions: heading_level=3 backlink="Resolved → "
Mechanical fixes: 14 applied, 0 deferred
Judgment findings: 3 acted on, 2 deferred (in state file)
Promotion candidates: RULE-ANCHOR-DRIFT (5/7 runs)
Touched files: decisions/architecture.md, decisions/questions.md
```
If this is the first run (no prior history) or there are fewer
than 5 prior runs, `Promotion candidates:` reports
`(none — N/7 runs of history)` instead of decorative empty content.
The summary names the touched files so the next commit message can be
honest about what changed. The skill does not commit on its own.
## Versioning
clean-house ships embedded in the pql binary; its version is the
pql version (`pql --version`). To see what changed and when, read
`CHANGELOG.md` or `git log internal/skill/clean-house/` in the pql
repo. Keep this file and `references/rules.md` consistent with
each other when iterating — they're the contract.
@@ -1,386 +0,0 @@
# clean-house rule catalog
The rules clean-house runs and the reasoning behind each. New rules
land here as their own entry. Catalog churn — additions, refinements,
retirements — is tracked in pql's `CHANGELOG.md` and `git log
internal/skill/clean-house/`. When a rule is retired, leave a
**Retired** stub (ID + one-line reason + retirement commit) so the
trail of "we used to check X" is recoverable from this file alone.
## Reading a rule
Each entry below has:
- **ID** — Stable identifier (e.g. `RULE-ANCHOR-DRIFT`). Used in
AskUserQuestion prompts and skip-ledger entries so the user learns
what's being checked.
- **Category** — `mechanical` or `judgment`. Drives whether findings
batch into one prompt or each get their own.
- **Finding ID** — Format string for the per-finding stable
identifier. Skip-ledger entries are written as `<rule-id>
<finding-id> <ISO-date>`; subsequent runs use the same format to
recognize "same finding skipped 3 runs in a row" and escalate.
Use only stable inputs (record IDs, file paths, slug strings, body
hashes) — never line numbers or timestamps.
- **Detection** — Concrete steps to find violations. Names the
pql command or file primitive used.
- **Fix** — Mechanical: deterministic action. Judgment: the prompt
option list and what each option does.
- **Why** — One paragraph on the failure mode this rule guards
against. Future-you reading the rule benefits from the reasoning,
not just the check.
---
## RULE-ANCHOR-DRIFT
**Category:** mechanical
**Finding ID:** `<source-record>:<link-target>` (e.g.
`D-8:#q-1-markdown-mirror-for-tickets` or
`D-8:questions.md#q-1`).
**Detection:**
Anchor-only markdown links (`[text](#slug)`) resolve against the
**source file's full heading set**, not the body of a single record
— record-level headings (`### D-N: …`) live as siblings in the
file and are valid link targets. The previous detection (which used
`pql decisions read`'s body-only `headings` array) missed these
and produced false positives.
Per source body:
1. Open the source body's file (`<vault>/<file_path>` from
`pql decisions list`) and extract every ATX heading. Build a
slug index for the file using the GFM convention (lowercase,
hyphenate spaces, drop punctuation, disambiguate duplicates with
`-1`/`-2`). Cache per file — every record in that file uses the
same index.
2. For each `[text](target)` link in the body:
- Anchor-only (`#slug`): check `slug` against the **source file's**
index. Missing → flag.
- Cross-file (`path.md#slug`): resolve `path.md` relative to the
source file's directory; check `slug` against that file's
index. Missing file or missing slug → flag.
**Fix:**
If the slug exists in a sibling file's index (same directory) and
the link is anchor-only, rewrite to `path.md#slug` (the canonical
cross-file form). If the slug exists in the source file's index
with edit-distance ≤ 2 from the link target, rewrite to the
matched slug. Otherwise downgrade to judgment ("no auto-fix; the
heading was removed, not renamed") with options: drop the link /
point elsewhere / mark as intentionally dangling.
**Fix:**
If exactly one heading with a closely-matching slug exists (slug
edit-distance ≤ 2 or substring match), rewrite the link to point at
that heading. If no plausible match exists, downgrade the finding to
judgment ("no auto-fix; the heading was removed, not renamed") and
prompt the user with options: drop the link / point elsewhere / mark
as intentionally dangling.
**Why:**
When a heading is renamed for clarity, every cross-reference pointing
at the old slug silently breaks. The rendered docs still look fine —
the link just goes nowhere. Without periodic sweeps these decay
indefinitely; the cost of the sweep is small compared to the cost of
a reader following a dead link and losing trust in the index.
---
## RULE-MISSING-Q-BACKLINK
**Category:** mechanical
**Finding ID:** `<d-id>:<q-id>` (e.g. `D-7:Q-2`). Order is always
D-first regardless of which side is missing the backlink.
**Detection:**
For each D record body, find lines of the shape `Resolves: Q-N`
(plain text, typically in the metadata block at the top of the
record). For each Q-N referenced, fetch that Q record's body via
`pql decisions read Q-N` and check whether it contains a line of the
shape `Resolved → D-N` (or equivalent backlink phrasing — match the
project's own convention; default pattern is `Resolved → D-N`).
Also check the reverse direction: any Q record claiming
`Resolved → D-N` whose D-N body lacks `Resolves: Q-N`.
**Fix:**
Insert the missing backlink in-place using `Edit`:
- Missing on the D side: add `Resolves: Q-N` to the D's metadata
block (typically right after `Domain:` / `Status:` lines).
- Missing on the Q side: append `Resolved → D-N` to the Q's status
line (or in the conventional position for that project).
**Why:**
Bidirectional Q↔D links are the navigation backbone of the DQR
system. When one side drifts, search-by-decision finds nothing for
that question and search-by-question doesn't surface its resolution.
The asymmetric state usually arises from a hand-edit on one record
that forgot to update the other; the fix is purely mechanical because
the correct content is already determined by the existing pointer
in the other direction.
---
## RULE-RECORD-SORT
**Category:** mechanical
**Finding ID:** `<file-path>` (e.g. `decisions/architecture.md`).
The whole file is one finding — sort applies to the file as a
unit.
**Detection:**
For each `decisions/*.md` file, read it directly (no pql) and find
all top-level `## D-N` / `## Q-N` / `## R-N` headings in order. Strip
the prefix, parse the numeric suffix, and check whether the sequence
is monotonically ascending within each ID family (D, Q, R kept
separate — files commonly mix families).
Only flag a file if it is **otherwise tidy** — currently no out-of-
order amendments interleaved with new records. The heuristic: if the
sort would touch fewer than three records out of position, apply it;
if more, downgrade to judgment ("this file looks deliberately
arranged — confirm before reordering"). Three is a soft threshold; if
the file's last commit message contains `WIP` or `do-not-sort`, skip.
**Fix:**
Reorder the records within the file so each ID family is ascending.
Preserve everything else (headings between record blocks, any prose
prelude/postlude). Use `Edit` with full block replacement, not in-
place line shuffling — easier to verify the diff.
**Why:**
Records added at the bottom of a file are easy to write but hard to
find. Sorted IDs let a reader find D-37 by jumping to the
two-thirds mark of the file rather than scanning. The cost of the
sort is one reordering pass; the benefit is paid back on every
subsequent read.
---
## RULE-EOF-NORMALIZATION
**Category:** mechanical
**Finding ID:** `<file-path>` (one finding per file).
**Detection:**
For each `decisions/*.md` file (and any other markdown the skill
touched during this run), read directly. Flag if:
- File does not end with exactly one `\n`.
- Any line contains trailing whitespace before its `\n`.
**Fix:**
Trim trailing whitespace from each line. Ensure exactly one trailing
newline at end of file. Use `Edit` only if a violation was found —
this rule must not produce a no-op diff.
**Why:**
Editor and git config drift causes whitespace creep that's invisible
in rendering but pollutes diffs (every record edit ends up touching
unrelated lines). A periodic normalization keeps future diffs clean
without forcing per-editor enforcement on every contributor.
---
## RULE-SUNSET-WITHOUT-TICKET
**Category:** judgment
**Finding ID:** `<record-id>:<phrase-hash-8>` where phrase-hash-8 is
the first 8 hex chars of `sha256(<matched-phrase>)`. Lets the
ledger distinguish two sunset phrases in the same record.
**Detection:**
For each D record body (via `pql decisions read <id>`), grep for
sunset-shaped phrases. Default regex set:
```
(?i)\b(delete|remove|sunset|kill[ -]?switch|tear[ -]?down) when\b
(?i)\bmust (track|monitor|watch|follow)\b
(?i)\brevisit (when|after|once)\b
(?i)\b(deprecate|retire) (when|after|once)\b
```
For each match, run `pql ticket list --decision <id>`. If no tickets
exist, the D has work-shaped intent without a tracked T — flag.
**Fix (prompt options):**
- **File T now** — Run `pql ticket new task "<phrase>" --decision <id>`,
then prompt the user for a description (or call `pql ticket refine
write <T> --description ...` after creation).
- **Draft a T description first** — Open an `AskUserQuestion` for the
description, then file as above.
- **Mark as already covered** — Add a note to the D body
("Tracked under T-N") via `Edit`. Skill does not assert which T;
user provides the ID.
- **Skip** — Adds to the ledger.
- **Stop asking** — Skip remaining sunset findings, summarize.
**Why:**
Sunset-shaped intent ("we'll handle X when Y happens") is the most
common source of accumulated debt in a DQR system: the trigger
condition arrives, nobody remembers the D, the work doesn't happen.
Linking each sunset to a T is the simplest defense — a T is a
backlog item that surfaces in `plan whatsnext` / `plan board`. The
fix is judgment because the right action depends on whether the D's
condition is still relevant, whether it's already covered, and what
the right scope is for the resulting T.
---
## RULE-FILE-OVER-THRESHOLD
**Category:** judgment
**Finding ID:** `<file-path>` (one finding per file; threshold
choice is judgment, not a per-line problem).
**Detection:**
For each `decisions/*.md` file, count lines (via `wc -l` or
equivalent). Default threshold: **350 lines**. Configurable per
project — read `decisions/.clean-house.yaml` if present, key
`file_threshold`. Fall back to 350.
**Fix (prompt options):**
- **Split now (which axis?)** — Prompt for the split axis: by ID
family (D/Q/R), by domain, by date range, custom. Then perform the
split: create new file(s), move records, update any anchor links
pointing into the moved records (run RULE-ANCHOR-DRIFT in apply
mode against the affected files after the split).
- **Accept and raise the threshold** — Update
`decisions/.clean-house.yaml`'s `file_threshold` to the next
reasonable round number above the current line count.
- **Defer** — Skip until next run.
**Why:**
A single decisions file growing past ~350 lines is the point at which
linear scanning starts to lose to grep, and where the cost of
splitting (renaming anchors) is still small. Beyond ~600 lines the
split cost compounds. The threshold is judgment because some projects
deliberately keep one file per domain and accept the size; others
split aggressively. The skill surfaces the question; it doesn't
decide.
---
## RULE-DEAD-FILE-REFERENCE
**Category:** judgment
**Finding ID:** `<record-id>:<path-token>` (path token as written
in the body, not resolved).
**Detection:**
For each D record body (via `pql decisions read <id>`), grep for
path-shaped tokens. Default regex:
```
\b([\w./-]+\.(md|go|py|sql|yaml|yml|toml))\b
```
The first detection pass produced 6/6 false positives in
real-world use; the regex is necessary but not sufficient. For
each match, apply the filters below in order — if any matches,
**skip without flagging**:
1. **Placeholder filter.** Token contains `T-NNN`, `D-NNN`,
`Q-NNN`, `R-NNN`, `...`, `<`, `>`, or `*` — it's a pattern,
not a real path.
2. **Source-relative resolution.** Resolve the token against the
source file's directory (`<vault>/<file_path>`'s dir). If
`[ -e <resolved> ]`, the reference is live.
3. **Repo-relative resolution.** If `[ -e <token> ]` from the
repo root, the reference is live.
4. **Basename-fallback.** Run `find <repo-root> -name <basename>`
(where basename is the last path component). If exactly one
match exists, treat the reference as live and emit a low-
priority "consider rewriting to the absolute path" note (not a
judgment finding). If multiple matches exist, do not flag —
the token is too ambiguous.
Only after all four filters miss does the reference qualify as
truly dead and warrant a judgment prompt.
**Fix (prompt options):**
- **Update reference** — Prompt for the new path; rewrite the
reference via `Edit`. If the user types a path, validate it exists
before applying.
- **Mark superseded** — Add a note to the D ("This decision
references files no longer present; the underlying constraint
was retired by D-N") and prompt for the superseding D ID.
- **Defer** — Skip until next run.
**Why:**
Decisions reference code paths to ground their reasoning in the
codebase that motivated them. When the code moves or gets deleted,
the D's reasoning becomes harder to verify. Dead refs are a signal
that either the decision should be updated or the decision itself is
no longer load-bearing — both are judgment calls the user needs to
make.
---
## RULE-STALE-OPEN-Q
**Category:** judgment
**Finding ID:** `<q-id>` (one finding per Q-record; staleness
threshold is global per run, so a Q is either stale or not).
**Detection:**
Run `pql decisions list --type question --status open`. For each
returned record, check the `date` field. Default staleness threshold:
**60 days** (configurable via `decisions/.clean-house.yaml` key
`stale_open_q_days`). Flag any Q with `date` older than the
threshold.
**Fix (prompt options):**
- **Still relevant** — Touch the Q's `date` field to today, optionally
add a one-line "still open as of YYYY-MM-DD: <reason>" to the body.
- **Nudge to load-bearing index** — If the project keeps a
load-bearing Qs list (a curated index of unresolved questions
affecting current work), prompt for inclusion and add the Q there.
- **Mark withdrawn** — Change the Q's status to `withdrawn` (the DQR
system's terminal state for "no longer worth answering"); the
user supplies a one-line reason.
- **Defer** — Skip until next run.
**Why:**
Open questions are useful when they're current; stale ones become
ambient noise that drowns the signal of new questions. Most
projects don't enforce explicit Q-lifecycle, so without a periodic
prompt the open list grows forever. The right action depends on
whether the question is still open in fact — not just in metadata —
which only the user can confirm.
+15 -13
View File
@@ -1,13 +1,12 @@
---
name: clide
description: >
Use when you are running inside the clide IDE and want to observe or drive
its live UI — panes, editor, files, git, readers, toasts, layout — through
the `clide` CLI, or to find out what commands clide exposes. clide is the
IDE hosting this session; it puts `clide` on your PATH and a per-workspace
socket in `CLIDE_SOCK`. Start with `clide capabilities` to enumerate the
live tool surface. Triggers: "what can clide do", "drive the clide UI",
"open this in clide", "show the user", "toast", or invoking /clide.
clide is the IDE hosting this session; it puts `clide` on your PATH and a
per-workspace socket in `CLIDE_SOCK`. Start with `clide capabilities` to
enumerate the live tool surface, then observe or drive the UI — panes,
editor, files, git, readers, toasts, layout — via `clide <subsystem> <verb>`.
Triggers: "what can clide do", "drive the clide UI", "open this in clide",
"show the user", "toast", or invoking /clide.
user-invocable: true
allowed-tools: Bash
---
@@ -15,9 +14,10 @@ allowed-tools: Bash
# Driving clide from the CLI
You are (often) running **inside clide** — a Flutter IDE that hosts this
Claude session. It exposes its whole UI surface as a `clide <subsystem> <verb>`
CLI on your PATH, talking to the running app over a per-workspace socket
(`CLIDE_SOCK`). Every UI action the user can take has a CLI verb, and every
Claude session. It puts `clide` on your PATH and exposes its whole UI surface
as a `clide <subsystem> <verb>` CLI, talking to the running app over a
per-workspace socket (`CLIDE_SOCK`). Every UI action the user can take has a
CLI verb, and every
verb's effect is observable — that is the parity contract (D-6). So you can
*see what the user sees* and *show the user what you mean*.
@@ -53,9 +53,11 @@ one). `clide <subsystem>` with no verb, or an unknown command, prints usage.
## Conventions
- **Slots:** the layout has three content slots — `sidebar` (left), `workspace`
(center, where Claude lives), `context` (right) — plus the bottom `statusbar`.
Many verbs take a slot.
- **Slots:** the layout has four content slots — `sidebar` (left), `workspace`
(center, where Claude lives), `context` (right), and `dock` (bottom —
Output/Problems panes, hidden by default; D-87) — plus the bottom
`statusbar`. Many verbs take a slot. The live list is whatever
`clide capabilities` reports.
- **Honest failures:** a drive verb with no live GUI returns a `toolError`
("no live UI to drive"), not a hang. JSON on stdout; exit code conveys
ok/usage/tool error.
-53
View File
@@ -1,53 +0,0 @@
---
name: commit
description: Create a well-formatted git commit with staged changes
---
# Git Commit
Create a well-formatted commit with staged changes following best practices.
## Steps
1. Run `git status --porcelain` to check for changes
2. If no staged changes, show unstaged files and ask what to stage
3. Run `git diff --cached` to review staged changes
4. Generate a commit message following Conventional Commits format:
- `feat:` new feature
- `fix:` bug fix
- `docs:` documentation
- `refactor:` code restructuring
- `test:` adding tests
- `chore:` maintenance
5. Create commit with the message, adding Co-Authored-By trailer
## Commit Message Format
```
<type>(<scope>): <short description>
<body - what and why, not how>
Co-Authored-By: Claude <noreply@anthropic.com>
```
## Best Practices
- Warn about large commits (>500 lines changed)
- Suggest splitting large changes into smaller commits
- Never skip pre-commit hooks unless explicitly requested
## Changelog
After committing, update `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format:
1. Add entry under `[Unreleased]` section (create if not exists)
2. Categorize changes:
- `Added` - new features
- `Changed` - changes to existing functionality
- `Deprecated` - features marked for removal
- `Removed` - removed features
- `Fixed` - bug fixes
- `Security` - security-related changes
3. Write entries in imperative mood: "Add feature" not "Added feature"
4. Reference issue numbers where applicable
-151
View File
@@ -1,151 +0,0 @@
---
name: d2-diagram
description: >
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
user says "create a diagram", "draw architecture", "make a flowchart",
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
Produces .d2 source files and renders them to PNG. Also use when asked
to update, re-render, or batch render existing diagrams.
---
# d2 Diagram Generation
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
external dependencies beyond the d2 binary.
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
## Project Defaults
| Setting | Value | Override |
|---------|-------|----------|
| Theme | 200 (Dark Mauve) | `--theme N` |
| Layout | dagre | `--layout elk` |
| Padding | 100px | — |
| Format | PNG | `--svg` |
## Output Convention
```
docs/diagrams/
architecture/ # System architecture, IPC, component layout
data-flow/ # Sequence diagrams, data pipelines
entity/ # ER diagrams, ECS component schemas
state/ # State machines, behavior trees
ui/ # UI navigation flow, screen transitions
```
Both `.d2` source and `.png` output are tracked in git.
## Single Diagram Workflow
1. **Determine category** — architecture, data-flow, entity, state, or ui
2. **Read template**`references/diagram-templates.md` for the matching category
3. **Read syntax**`references/d2-syntax-guide.md` if unfamiliar with d2 syntax
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
5. **Validate**`.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
6. **Render**`.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
7. **Read SVG** — verify the output, present to user
### Script Usage
```bash
# Render with project defaults
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
# Validate syntax only
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
# Auto-format source
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
# Sketch mode (hand-drawn look for drafts)
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
# Light theme (for printable docs)
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
# SVG output (if specifically needed)
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
```
## Batch Render
Re-render all diagrams after theme or style changes:
```bash
# All diagrams
.claude/skills/d2-diagram/scripts/d2-batch.sh
# One category
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
# Preview what would render
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
# Force re-render everything
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
```
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
## Advanced Patterns
### Variables for consistent styling
```d2
vars: {
color-bg: "#2a3040"
color-stroke: "#333340"
color-text: "#c8d0e0"
color-accent: "#c8d8f0"
}
```
### Multi-board (layers)
```d2
# Base diagram here
layers: {
detailed: {
# More detailed view
}
}
```
### Sequence diagrams
```d2
shape: sequence_diagram
client: Godot Client
server: Rust Server
client -> server: TickRequest(delta)
server -> client: WorldState(entities)
```
### Imports
Split shared definitions into a separate file and import:
```d2
...@shared-defs.d2
```
## Agent Guidance
- **Qatux** — Architecture decision records, system overview diagrams, data
schemas. Prefer architecture and entity templates.
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
system data flow. Prefer architecture and data-flow templates.
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
Prefer UI flow template.
## References
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
containers, styling, variables). Read when unfamiliar with d2 syntax.
- `references/diagram-templates.md` — Five category templates with complete
d2 source examples. Read when starting a new diagram.
@@ -1,212 +0,0 @@
# D2 Language Quick Reference
## Nodes
```d2
server # Implicit label from key
server: Simulation Server # Explicit label
server: Simulation Server { # With properties
shape: hexagon
style.fill: "#2d3436"
}
```
## Edges
```d2
a -> b # Directed
a <- b # Reverse directed
a <-> b # Bidirectional
a -- b # Undirected
a -> b: "label" # Labeled edge
a -> b -> c # Chained
```
## Containers (nesting)
```d2
infrastructure: {
server: Simulation Server
database: State Store {
shape: cylinder
}
}
```
## Shapes
| Shape | Use for |
|-------|---------|
| `rectangle` | Default. Components, modules, generic. |
| `hexagon` | Systems, services, major components. |
| `cylinder` | Databases, storage, persistent state. |
| `diamond` | Decisions, conditions, branch points. |
| `oval` / `circle` | Start/end states, events. |
| `cloud` | External systems, networks. |
| `person` | Actors, users, NPCs. |
| `queue` | Message queues, buffers. |
| `page` | Documents, files. |
| `package` | Packages, modules, crates. |
| `sql_table` | Database tables, ECS component schemas. |
| `class` | Class diagrams, ECS system definitions. |
| `code` | Code blocks (set `language` property). |
| `markdown` | Rich text blocks. |
## SQL Tables
```d2
entity: {
shape: sql_table
id: u64 {constraint: primary_key}
name: String
position: Vec2
faction_id: u64 {constraint: foreign_key}
}
```
## Class Diagrams
```d2
perception_system: {
shape: class
+run(world: &mut World)
-calculate_los(entity: Entity): HashSet<Entity>
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
}
```
## Sequence Diagrams
```d2
shape: sequence_diagram
client: Godot Client
server: Rust Server
client -> server: TickRequest(delta)
server -> server: run ECS systems
server -> client: WorldState(entities)
```
## Styling
```d2
node: Label {
style: {
fill: "#2d3436"
stroke: "#333340"
stroke-width: 2
stroke-dash: 5 # Dashed line
opacity: 0.8
font-size: 14
font-color: "#c8d0e0"
bold: true
italic: false
border-radius: 4
shadow: true
3d: true # Rectangles only
multiple: true # Stacked appearance
double-border: true # Rectangles/ovals only
}
}
```
### Edge styling
```d2
a -> b: {
style: {
stroke: "#c8d8f0"
stroke-width: 2
stroke-dash: 5
opacity: 0.8
animated: true # Animated flow
}
}
```
## Variables
```d2
vars: {
color-bg: "#1a1e24"
color-stroke: "#333340"
color-text: "#c8d0e0"
color-accent: "#c8d8f0"
}
node: {
style.fill: ${color-bg}
style.stroke: ${color-stroke}
style.font-color: ${color-text}
}
```
## Direction
```d2
direction: right # left-to-right (default for dagre)
direction: down # top-to-bottom
direction: up
direction: left
```
## Imports
```d2
...@shared-defs.d2 # Spread import (inline all definitions)
```
## Icons
```d2
node: Label {
icon: https://icons.terrastruct.com/essentials/time.svg
}
```
## Layers (multi-board)
```d2
# Base diagram content here
layers: {
detailed: {
# More detailed view
}
simplified: {
# Simplified overview
}
}
```
## Scenarios (animated transitions)
```d2
# Base state
scenarios: {
alert: {
# Changes from base for alert state
}
combat: {
# Changes from base for combat state
}
}
```
## Comments
```d2
# This is a comment
node: Label # Inline comment
```
## Project Colors (from visual-grammar-v01.md)
| Constant | Hex | Usage |
|----------|-----|-------|
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
| Zone 1 wall | `#2a3040` | Component fill |
| Outline standard | `#333340` | Borders, strokes |
| Insert chrome | `#c8d0e0` | Text, labels |
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
@@ -1,251 +0,0 @@
# Diagram Templates
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
---
## 1. Architecture Diagram
System components, relationships, communication channels.
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
```d2
vars: {
color-bg: "#2a3040"
color-stroke: "#333340"
color-text: "#c8d0e0"
color-accent: "#c8d8f0"
}
direction: right
client: Godot Client {
shape: hexagon
style.fill: ${color-bg}
style.font-color: ${color-text}
rendering: Rendering {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
ui: UI Layer {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
bridge: IPC Bridge {
style.fill: ${color-bg}
style.font-color: ${color-text}
style.stroke: ${color-accent}
}
}
server: Rust Server {
shape: hexagon
style.fill: ${color-bg}
style.font-color: ${color-text}
ecs: bevy_ecs {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
perception: Perception {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
bridge: IPC Bridge {
style.fill: ${color-bg}
style.font-color: ${color-text}
style.stroke: ${color-accent}
}
}
client.bridge -> server.bridge: "stdin/stdout" {
style.stroke: ${color-accent}
style.stroke-dash: 5
}
```
---
## 2. Entity Relationship
Data schemas, ECS components, knowledge graph structure.
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
```d2
entity: Entity {
shape: sql_table
id: u64 {constraint: primary_key}
name: String
faction_id: u64 {constraint: foreign_key}
}
position: Position {
shape: sql_table
entity_id: u64 {constraint: foreign_key}
x: f32
y: f32
chunk_id: u32
}
knowledge: KnowledgeEntry {
shape: sql_table
observer_id: u64 {constraint: foreign_key}
subject_id: u64 {constraint: foreign_key}
fact_type: FactType
confidence: f32
last_seen_tick: u64
}
entity.id -> position.entity_id
entity.id -> knowledge.observer_id
entity.id -> knowledge.subject_id
```
---
## 3. Sequence / Data Flow
Ordered interactions between systems over time.
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
```d2
shape: sequence_diagram
client: Godot Client
bridge: IPC Bridge
server: Rust Server
ecs: ECS Systems
client -> bridge: TickRequest(delta, input)
bridge -> server: deserialize + dispatch
server -> ecs: run_systems(delta)
ecs -> ecs: perception, AI, physics
ecs -> server: collect WorldState
server -> bridge: serialize WorldState
bridge -> client: WorldState(entities, events)
client -> client: update rendering
```
---
## 4. State Machine
Entity states, transitions, conditions.
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
**Agents:** Tyre (behavior system design), Qatux (state documentation).
```d2
vars: {
color-state: "#2a3040"
color-text: "#c8d0e0"
color-edge: "#c8d8f0"
color-decision: "#333340"
}
idle: Idle {
style.fill: ${color-state}
style.font-color: ${color-text}
}
alert: Alert {
style.fill: ${color-state}
style.font-color: ${color-text}
}
investigate: Investigate {
style.fill: ${color-state}
style.font-color: ${color-text}
}
combat: Combat {
style.fill: ${color-state}
style.font-color: ${color-text}
style.stroke: "#f0b840"
}
flee: Flee {
style.fill: ${color-state}
style.font-color: ${color-text}
}
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
```
---
## 5. UI Flow
Screen navigation, component hierarchy, interaction paths.
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
```d2
vars: {
color-screen: "#1a1e24"
color-panel: "#2a3040"
color-text: "#c8d0e0"
color-nav: "#c8d8f0"
}
gameplay: Gameplay {
style.fill: ${color-screen}
style.font-color: ${color-text}
hud: HUD {
style.fill: ${color-panel}
style.font-color: ${color-text}
minimap: Minimap
monologue: Monologue Panel
insert_display: Insert Display
}
world: World View {
style.fill: ${color-panel}
style.font-color: ${color-text}
}
}
pause: Pause Menu {
style.fill: ${color-screen}
style.font-color: ${color-text}
inventory: Inventory
journal: Journal
settings: Settings
}
dialogue: Dialogue Mode {
style.fill: ${color-screen}
style.font-color: ${color-text}
speaker: Speaker Panel
responses: Response List
}
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
```
@@ -1,84 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
RENDER="$SCRIPT_DIR/d2-render.sh"
usage() {
cat <<EOF
Usage: $(basename "$0") [directory] [options]
Batch render all .d2 files in a directory. Skips files whose PNG is
newer than the source unless --force is used.
Options:
--dry-run List files that would be rendered
--force Re-render even if SVG is up to date
--theme N Override theme for all files
Examples:
$(basename "$0") # All in docs/diagrams/
$(basename "$0") docs/diagrams/architecture/ # One category
$(basename "$0") --dry-run # Preview
$(basename "$0") --force # Re-render everything
EOF
exit 1
}
DIR="$REPO_ROOT/docs/diagrams"
DRY_RUN=false
FORCE=false
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--force) FORCE=true; shift ;;
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
--help|-h) usage ;;
*)
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
DIR="$1"
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
else
echo "Unknown option or directory: $1" >&2; exit 1
fi
shift
;;
esac
done
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
RENDERED=0
SKIPPED=0
FAILED=0
while IFS= read -r -d '' d2_file; do
png_file="${d2_file%.d2}.png"
# Skip if PNG is newer than source (unless --force)
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
SKIPPED=$((SKIPPED + 1))
continue
fi
rel_path="${d2_file#"$REPO_ROOT/"}"
if [[ "$DRY_RUN" == true ]]; then
echo "Would render: $rel_path"
RENDERED=$((RENDERED + 1))
continue
fi
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
RENDERED=$((RENDERED + 1))
else
echo "FAILED: $rel_path" >&2
FAILED=$((FAILED + 1))
fi
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
echo ""
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
@@ -1,92 +0,0 @@
#!/bin/bash
set -euo pipefail
D2="/home/linuxbrew/.linuxbrew/bin/d2"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
DEFAULT_THEME=200
DEFAULT_LAYOUT="dagre"
DEFAULT_PAD=100
usage() {
cat <<EOF
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
Commands:
validate <file> Check syntax without rendering
fmt <file> Auto-format in place
Options:
--theme N Override theme (default: $DEFAULT_THEME)
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
--sketch Enable hand-drawn sketch mode
--output PATH Override output path (default: input with .png extension)
--svg Render to SVG instead of PNG
Examples:
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
EOF
exit 1
}
[[ $# -lt 1 ]] && usage
# Parse subcommand
SUBCMD=""
case "$1" in
validate|fmt)
SUBCMD="$1"
shift
;;
esac
[[ $# -lt 1 ]] && usage
INPUT="$1"
shift
# Resolve to absolute path
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
# Handle subcommands
if [[ -n "$SUBCMD" ]]; then
"$D2" "$SUBCMD" "$INPUT"
echo "OK: $SUBCMD $INPUT"
exit 0
fi
# Parse render options
THEME="$DEFAULT_THEME"
LAYOUT="$DEFAULT_LAYOUT"
SKETCH=""
OUTPUT=""
FORMAT="png"
while [[ $# -gt 0 ]]; do
case "$1" in
--theme) THEME="$2"; shift 2 ;;
--layout) LAYOUT="$2"; shift 2 ;;
--sketch) SKETCH="-s"; shift ;;
--output) OUTPUT="$2"; shift 2 ;;
--svg) FORMAT="svg"; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Derive output path
if [[ -z "$OUTPUT" ]]; then
OUTPUT="${INPUT%.d2}.$FORMAT"
fi
# Render
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
-222
View File
@@ -1,222 +0,0 @@
---
name: frame0-wireframe
description: >
Create and export UI wireframes using Frame0 (local desktop wireframing
app with HTTP API). Use when the user says "create wireframe", "wireframe
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
Wireframes are authored as local JSON files (source of truth) and synced
to Frame0 for rendering and export. Requires Frame0 to be running locally.
---
# Frame0 Wireframe Generation
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
the user explicitly says they have made edits in Frame0 and want to import
them. The pull workflow exists for that case only — do not use it proactively.
**Prerequisite:** Frame0 desktop app must be running. If not available,
stop and inform the user. Point to `references/setup-guide.md`.
## Health Check
Always check first:
```bash
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
```
## Core Workflow
Wireframing is a **design conversation, not a batch job.** Produce **one screen
at a time**, show it, and **stop for the user's reaction** before authoring the
next. Each shown screen is a checkpoint — never fan out a whole set of
wireframes unprompted, even if the user described several screens up front.
1. **Health check** — verify Frame0 is running
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`.
Author **one** screen.
3. **Push to Frame0**`frame0-sync.py push <file.json>`
4. **Export PNG**`frame0-sync.py export <file.json> <output.png>`
5. **Show it inline** — surface the exported PNG in the conversation so the
user sees the result without opening Frame0. Always do this after an export,
and again whenever you re-export or the user asks to see it.
- **Inside clide** (the IDE hosting this session — `CLIDE_SOCK` is set):
render it as a native image card in the live Claude pane with
`clide image show <path> [--caption "…"]` (T-249). This is the *proper*
way to put an image in the clide conversation — a bare `Read` only shows
it in the transcript, not in the UI the user is looking at.
- **Otherwise**: `Read` the PNG so it renders inline in the transcript.
6. **STOP and wait for approval** — after showing a screen, pause and ask the
user for feedback. Do **not** author, push, or export the next screen until
they give an explicit go-ahead. Iterate on the current screen until they're
happy, then proceed to the next one — looping back to step 2 for each.
7. **Clean up** — delete test/scratch pages from Frame0 when done
> **Multi-screen requests are still one-at-a-time.** If the user asks for
> several screens, treat it as a queue: build the first, show it, get approval,
> then move on. The only exception is an explicit, unambiguous instruction to
> generate a batch without stopping (and even then prefer the batch-export
> dry-run + approval flow below).
### Scripts
| Script | Purpose |
|--------|---------|
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
## Wireframe JSON Format
```json
{
"name": "Dialogue Box",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 170, "top": 500, "width": 800, "height": 260,
"fillColor": "#1a1e24",
"strokeColor": "#333340",
"corners": [8, 8, 8, 8]
},
"speaker": {
"type": "Text",
"parent": "panel",
"left": 190, "top": 520,
"text": "LERA KONSTANTIN",
"fontColor": "#c8d0e0",
"fontSize": 16
},
"btn-ask": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 670, "width": 370, "height": 30,
"fillColor": "#2a3040",
"strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
}
},
"connectors": {
"flow-1": {
"tailId": "panel",
"headId": "btn-ask",
"strokeColor": "#c8d8f0"
}
}
}
```
### Key rules
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
- **`parent`** references another local shape ID for nesting
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
theme tokens for colors). The sync script handles the mapping transparently.
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
### Sync commands
```bash
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
# Push local JSON to Frame0 (clears page, recreates all shapes)
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
# Push + export as PNG in one step
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
docs/design/wireframes/dialogue/dialogue-box.png
```
### Batch export
Use this when exporting multiple wireframes. It runs as a single Bash call,
avoiding repeated permission prompts.
```bash
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
# Dry run first — shows full manifest, no Frame0 calls
$BATCH --dry-run
# Export everything (skips PNGs already newer than their JSON)
$BATCH
# Export one category only
$BATCH --category dialogue
# Force re-export of everything
$BATCH --force
```
**Always dry-run first, then get approval before running the live export.**
## Low-Level Commands
For ad-hoc operations or exec calls not covered by sync:
```bash
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
$CMD health
$CMD list-pages
$CMD current-page
$CMD get-page <page-id>
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
$CMD create-connector <tail-id> <head-id>
$CMD move <shape-id> <dx> <dy>
$CMD export --format image/png
$CMD exec "view:fit-to-screen" '{}'
```
If you find yourself using `exec` for the same command repeatedly, flag it as
a candidate for a proper wrapper in `frame0-cmd.sh`.
## Project Styling Defaults
Colors from `docs/design/visual-grammar-v01.md`:
| Role | Hex | Frame0 token |
|------|-----|-------------|
| Background | `#1a1e24` | `$sage3` |
| Stroke | `#333340` | `$slate6` |
| Fill | `#2a3040` | `$slate5` |
| Text | `#c8d0e0` | `$mint12` |
| Accent | `#c8d8f0` | `$blue12` |
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
## Output Convention
```
docs/design/wireframes/
hud/ # HUD layout wireframes
menus/ # Menu screen wireframes
dialogue/ # Dialogue box wireframes
insert/ # Neural insert wireframes
```
Both `.json` source and `.png` exports are tracked in git.
`.idmap.json` mapping files are gitignored.
## Agent Guidance
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
visual consistency. Uses all component library patterns.
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
- **Qatux** — Export wireframes for UI decision records and documentation.
## References
- `references/component-library.md` — Pre-built JSON wireframe templates
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
- `references/api-reference.md` — Frame0 HTTP API command reference and
type mappings. Read for low-level control.
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
@@ -1,241 +0,0 @@
# Frame0 HTTP API Reference
Frame0 exposes a local HTTP API when the desktop app is running.
## Endpoint
```
POST http://localhost:{port}/execute_command
Content-Type: application/json
```
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
## Request / Response
```json
{"command": "namespace:action", "args": { ... }}
```
```json
{"success": true, "data": { ... }}
{"success": false, "error": "description"}
```
---
## Type Mapping
Frame0 uses different type names for create vs get:
| Create API (`type`) | Get API (internal) | Description |
|--------------------|--------------------|-------------|
| `Rectangle` | `Box` | Rectangle with optional corners |
| `Ellipse` | `Ellipse` | Circle/ellipse |
| `Text` | `Text` | Text label |
| `Line` | `Line` | Line/polyline |
| `Frame` | `Frame` | Container from library |
| `Freehand` | `Freehand` | Freehand drawing |
| `Highlighter` | `Highlighter` | Highlighter stroke |
The sync script handles this mapping transparently.
## Color Tokens
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
| Hex | Token | Role |
|-----|-------|------|
| `#1a1e24` | `$sage3` | Background |
| `#2a3040` | `$slate5` | Fill |
| `#333340` | `$slate6` | Stroke |
| `#c8d0e0` | `$mint12` | Text |
| `#c8d8f0` | `$blue12` | Accent |
Both hex and token strings work in the API. Tokens are preserved on round-trip.
---
## Commands
### shape:create-shape
```json
{
"command": "shape:create-shape",
"args": {
"type": "Rectangle",
"shapeProps": {
"name": "my-button",
"left": 100, "top": 200, "width": 120, "height": 36,
"fillColor": "#2a3040",
"strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"parentId": "optional-parent-shape-id",
"convertColors": true
}
}
```
Returns: shape ID (string).
### shape:get-shape
```json
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
```
### shape:update-shape
```json
{
"command": "shape:update-shape",
"args": {
"shapeId": "id",
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
"convertColors": true
}
}
```
### shape:move
```json
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
```
### shape:create-connector
```json
{
"command": "shape:create-connector",
"args": {
"tailId": "source-id",
"headId": "target-id",
"shapeProps": {"strokeColor": "#c8d8f0"},
"convertColors": true
}
}
```
### shape:create-icon
```json
{
"command": "shape:create-icon",
"args": {
"iconName": "search",
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
}
}
```
### shape:get-available-icons
```json
{"command": "shape:get-available-icons", "args": {}}
```
### shape:group / shape:ungroup
```json
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
```
### edit:delete / edit:duplicate
```json
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
```
### page:add
```json
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
```
Returns: `{id, type, name}`.
### page:get
```json
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
```
### page:get-current-page
```json
{"command": "page:get-current-page", "args": {}}
```
Returns: page ID string.
### page:set-current-page
```json
{"command": "page:set-current-page", "args": {"pageId": "id"}}
```
### doc:get (list all pages)
```json
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
```
### page:delete
```json
{"command": "page:delete", "args": {"pageId": "id"}}
```
### file:export-image
```json
{
"command": "file:export-image",
"args": {
"pageId": "optional-page-id",
"format": "image/png",
"fillBackground": true
}
}
```
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
Returns: base64-encoded image data.
### view:fit-to-screen
```json
{"command": "view:fit-to-screen", "args": {}}
```
---
## Shape Properties
| Property | Type | Notes |
|----------|------|-------|
| `name` | string | Shape identifier/label |
| `left` | number | X position (origin: top-left) |
| `top` | number | Y position |
| `width` | number | Width in pixels |
| `height` | number | Height in pixels |
| `fillColor` | string | Hex or `$token` |
| `strokeColor` | string | Hex or `$token` |
| `strokeWidth` | number | Border width |
| `fontColor` | string | Text color (hex or `$token`) |
| `fontSize` | number | Font size in pixels |
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
| `text` | string | Text content |
| `wordWrap` | boolean | Enable word wrapping |
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
| `constraints` | array | Auto-sizing constraints |
| `horzAlign` | string | Horizontal text alignment |
| `vertAlign` | string | Vertical text alignment |
| `fillStyle` | string | Fill style (`none` for transparent) |
| `path` | array | Coordinate pairs for lines |
@@ -1,467 +0,0 @@
# Component Library
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
**Viewport:** 1140x780 (Godot project settings)
**Grid unit:** 8px
**Min touch target:** 36px height
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
---
## 1. HUD Layout
Main gameplay overlay. Minimap top-right, monologue bottom-center,
insert display bottom-left, action hints bottom-right.
```json
{
"name": "HUD Layout",
"shapes": {
"minimap": {
"type": "Rectangle",
"left": 880, "top": 20, "width": 240, "height": 240,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"minimap-label": {
"type": "Text",
"parent": "minimap",
"left": 890, "top": 30,
"text": "Minimap",
"fontColor": "#c8d0e0", "fontSize": 14
},
"monologue": {
"type": "Rectangle",
"left": 300, "top": 680, "width": 520, "height": 80,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"monologue-text": {
"type": "Text",
"parent": "monologue",
"left": 310, "top": 700, "width": 500,
"text": "Internal monologue text appears here...",
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
},
"insert": {
"type": "Rectangle",
"left": 20, "top": 600, "width": 260, "height": 160,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"insert-label": {
"type": "Text",
"parent": "insert",
"left": 30, "top": 620,
"text": "Neural Insert Data",
"fontColor": "#c8d0e0", "fontSize": 14
},
"hints": {
"type": "Rectangle",
"left": 880, "top": 700, "width": 240, "height": 60,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"hints-label": {
"type": "Text",
"parent": "hints",
"left": 890, "top": 720,
"text": "[E] Interact [TAB] Insert",
"fontColor": "#c8d0e0", "fontSize": 12
}
}
}
```
---
## 2. Dialogue Box
Speaker panel with response options. Anchored bottom-center during dialogue mode.
```json
{
"name": "Dialogue Box",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 170, "top": 500, "width": 800, "height": 260,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [8, 8, 8, 8]
},
"speaker": {
"type": "Text",
"parent": "panel",
"left": 190, "top": 520,
"text": "LERA KONSTANTIN",
"fontColor": "#c8d0e0", "fontSize": 16
},
"text-area": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 550, "width": 760, "height": 100,
"fillColor": "#2a3040", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"dialogue-text": {
"type": "Text",
"parent": "text-area",
"left": 200, "top": 560, "width": 740,
"text": "You look like you could use a drink. First time on the station?",
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
},
"btn-option1": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 670, "width": 370, "height": 30,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"btn-option1-label": {
"type": "Text",
"parent": "btn-option1",
"left": 200, "top": 674,
"text": "[1] Ask about the station",
"fontColor": "#c8d8f0", "fontSize": 12
},
"btn-option2": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 710, "width": 370, "height": 30,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"btn-option2-label": {
"type": "Text",
"parent": "btn-option2",
"left": 200, "top": 714,
"text": "[2] Ask about recent events",
"fontColor": "#c8d8f0", "fontSize": 12
},
"btn-leave": {
"type": "Rectangle",
"parent": "panel",
"left": 580, "top": 670, "width": 180, "height": 30,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"btn-leave-label": {
"type": "Text",
"parent": "btn-leave",
"left": 590, "top": 674,
"text": "[3] Leave",
"fontColor": "#c8d8f0", "fontSize": 12
}
}
}
```
---
## 3. Menu Screen
Full-screen menu with sidebar navigation and content area.
```json
{
"name": "Pause Menu",
"shapes": {
"bg": {
"type": "Rectangle",
"left": 0, "top": 0, "width": 1140, "height": 780,
"fillColor": "#1a1e24"
},
"nav": {
"type": "Rectangle",
"parent": "bg",
"left": 20, "top": 20, "width": 200, "height": 740,
"fillColor": "#2a3040", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"btn-inventory": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 40, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-inventory-label": {
"type": "Text", "parent": "btn-inventory",
"left": 40, "top": 48, "text": "Inventory",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-journal": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 86, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-journal-label": {
"type": "Text", "parent": "btn-journal",
"left": 40, "top": 94, "text": "Journal",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-map": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 132, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-map-label": {
"type": "Text", "parent": "btn-map",
"left": 40, "top": 140, "text": "Map",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-settings": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 178, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-settings-label": {
"type": "Text", "parent": "btn-settings",
"left": 40, "top": 186, "text": "Settings",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-resume": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 720, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-resume-label": {
"type": "Text", "parent": "btn-resume",
"left": 40, "top": 728, "text": "Resume",
"fontColor": "#c8d8f0", "fontSize": 14
},
"content": {
"type": "Rectangle",
"parent": "bg",
"left": 240, "top": 20, "width": 880, "height": 740,
"fillColor": "#2a3040", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"content-label": {
"type": "Text", "parent": "content",
"left": 260, "top": 40,
"text": "Content area",
"fontColor": "#c8d0e0", "fontSize": 14
}
}
}
```
---
## 4. Modal Dialog
Centered overlay for confirmations, alerts, choices.
```json
{
"name": "Modal Dialog",
"shapes": {
"overlay": {
"type": "Rectangle",
"left": 0, "top": 0, "width": 1140, "height": 780,
"fillColor": "#0a0c10"
},
"modal": {
"type": "Rectangle",
"parent": "overlay",
"left": 320, "top": 240, "width": 500, "height": 300,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [8, 8, 8, 8]
},
"title": {
"type": "Text", "parent": "modal",
"left": 340, "top": 260,
"text": "Confirm Action",
"fontColor": "#c8d0e0", "fontSize": 18
},
"divider": {
"type": "Line", "parent": "modal",
"left": 340, "top": 290, "width": 460, "height": 0,
"strokeColor": "#333340"
},
"body-1": {
"type": "Text", "parent": "modal",
"left": 340, "top": 310,
"text": "Are you sure you want to proceed?",
"fontColor": "#c8d0e0", "fontSize": 14
},
"body-2": {
"type": "Text", "parent": "modal",
"left": 340, "top": 340,
"text": "This action cannot be undone.",
"fontColor": "#c8d0e0", "fontSize": 14
},
"btn-cancel": {
"type": "Rectangle", "parent": "modal",
"left": 480, "top": 480, "width": 120, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-cancel-label": {
"type": "Text", "parent": "btn-cancel",
"left": 510, "top": 488,
"text": "Cancel",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-confirm": {
"type": "Rectangle", "parent": "modal",
"left": 620, "top": 480, "width": 120, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-confirm-label": {
"type": "Text", "parent": "btn-confirm",
"left": 645, "top": 488,
"text": "Confirm",
"fontColor": "#c8d8f0", "fontSize": 14
}
}
}
```
---
## 5. List View
Scrollable list with item selection and detail panel.
```json
{
"name": "List View",
"shapes": {
"list-panel": {
"type": "Rectangle",
"left": 20, "top": 20, "width": 400, "height": 740,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"item-1": {
"type": "Rectangle", "parent": "list-panel",
"left": 30, "top": 30, "width": 380, "height": 40,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"item-1-label": {
"type": "Text", "parent": "item-1",
"left": 40, "top": 38, "text": "Item Alpha",
"fontColor": "#c8d8f0", "fontSize": 14
},
"item-2": {
"type": "Rectangle", "parent": "list-panel",
"left": 30, "top": 80, "width": 380, "height": 40,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"item-2-label": {
"type": "Text", "parent": "item-2",
"left": 40, "top": 88, "text": "Item Beta",
"fontColor": "#c8d0e0", "fontSize": 14
},
"item-3": {
"type": "Rectangle", "parent": "list-panel",
"left": 30, "top": 130, "width": 380, "height": 40,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"item-3-label": {
"type": "Text", "parent": "item-3",
"left": 40, "top": 138, "text": "Item Gamma",
"fontColor": "#c8d0e0", "fontSize": 14
},
"detail-panel": {
"type": "Rectangle",
"left": 440, "top": 20, "width": 680, "height": 740,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"detail-title": {
"type": "Text", "parent": "detail-panel",
"left": 460, "top": 40,
"text": "Item Alpha",
"fontColor": "#c8d0e0", "fontSize": 18
},
"detail-body": {
"type": "Text", "parent": "detail-panel",
"left": 460, "top": 80, "width": 640,
"text": "Description and properties appear here.",
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
}
}
}
```
---
## 6. Inventory Grid
Grid of cells for item management.
```json
{
"name": "Inventory Grid",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 240, "top": 100, "width": 660, "height": 580,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
},
"title": {
"type": "Text", "parent": "panel",
"left": 260, "top": 120,
"text": "INVENTORY",
"fontColor": "#c8d0e0", "fontSize": 18
},
"cell-1-1": {
"type": "Rectangle", "parent": "panel",
"left": 260, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-1-2": {
"type": "Rectangle", "parent": "panel",
"left": 332, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-1-3": {
"type": "Rectangle", "parent": "panel",
"left": 404, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-1-4": {
"type": "Rectangle", "parent": "panel",
"left": 476, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-1": {
"type": "Rectangle", "parent": "panel",
"left": 260, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-2": {
"type": "Rectangle", "parent": "panel",
"left": 332, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-3": {
"type": "Rectangle", "parent": "panel",
"left": 404, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-4": {
"type": "Rectangle", "parent": "panel",
"left": 476, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"detail": {
"type": "Rectangle", "parent": "panel",
"left": 580, "top": 160, "width": 300, "height": 400,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"detail-title": {
"type": "Text", "parent": "detail",
"left": 600, "top": 180,
"text": "Selected Item Name",
"fontColor": "#c8d0e0", "fontSize": 16
},
"detail-body": {
"type": "Text", "parent": "detail",
"left": 600, "top": 210, "width": 260,
"text": "Item description and stats",
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
}
}
}
```
@@ -1,53 +0,0 @@
# Frame0 Setup Guide
## Installation (Fedora)
Download from https://frame0.app/download and install the RPM:
```bash
sudo dnf install ./frame0-*.x86_64.rpm
```
Requires: Fedora 40 or later (x86_64).
## Starting Frame0
Launch the desktop application:
```bash
frame0 &
```
Frame0 exposes an HTTP API at `localhost:58320` when running.
## Verify API Access
```bash
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
```
Expected output: `Frame0 is running on port 58320`
## Port Configuration
Default port: **58320**
To use a different port, set the environment variable:
```bash
export FRAME0_PORT=58321
```
Or pass `--port` to any script:
```bash
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| "Connection refused" | Frame0 not running | Start the desktop app |
| "Port in use" | Another instance running | Close duplicate or use different port |
| Script hangs | API unresponsive | Restart Frame0 |
@@ -1,253 +0,0 @@
#!/bin/bash
set -euo pipefail
PORT="${FRAME0_PORT:-58320}"
ENDPOINT="http://localhost:${PORT}/execute_command"
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [args...] [--port N]
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
curl calls. Requires Frame0 desktop app to be running.
Commands:
health Check if Frame0 is running
exec <namespace:action> <json> Execute a raw API command
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
get-shape <id> Get shape details
update-shape <id> <json-props> Update shape properties
delete <id> [id...] Delete shapes by ID
move <id> <dx> <dy> Move a shape by pixel offset
duplicate <id> Duplicate a shape
group <id> [id...] Group shapes
ungroup <group-id> Ungroup a group
create-connector <tail-id> <head-id> [json-props] Connect two shapes
create-icon <name> <json-props> Create an icon shape
add-page <name> Add a new page (becomes current)
get-page [page-id] Get current or specific page data
list-pages [--shapes] List all pages (--shapes for shape data)
current-page Get current page ID
set-page <page-id> Set current page
export [page-id] [--format mime] Export page as image (default: image/png)
fit Fit view to screen
Options:
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
Examples:
$(basename "$0") health
$(basename "$0") add-page "HUD Layout"
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
$(basename "$0") list-pages
$(basename "$0") export --format image/png
EOF
exit 1
}
# Parse --port from anywhere in args
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
set -- "${ARGS[@]+"${ARGS[@]}"}"
[[ $# -lt 1 ]] && usage
# Execute a Frame0 API command, return data or error
frame0_exec() {
local command="$1"
local args
args="${2:-"{}"}"
local response
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
return 1
}
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != 2* ]]; then
echo "ERROR: HTTP $http_code from Frame0" >&2
echo "$body" >&2
return 1
fi
# Parse success/error from response
python3 -c "
import sys, json
try:
r = json.loads(sys.stdin.read())
if r.get('success'):
d = r.get('data')
if d is not None:
print(json.dumps(d, indent=2))
else:
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
sys.exit(1)
" <<< "$body"
}
# Build JSON array from remaining args
ids_to_json_array() {
local arr="["
local first=true
for id in "$@"; do
[[ "$first" == true ]] && first=false || arr+=","
arr+="\"$id\""
done
arr+="]"
echo "$arr"
}
CMD="${1:-}"
shift || true
case "$CMD" in
health)
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
echo "Frame0 is running on port $PORT"
else
echo "Frame0 is NOT running on port $PORT" >&2
echo "Start Frame0 desktop app, then retry." >&2
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
exit 1
fi
;;
exec)
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
frame0_exec "$1" "$2"
;;
create-shape)
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
local_type="$1"
local_props="$2"
local_parent="${3:-}"
local_parent_arg=""
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
;;
get-shape)
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
;;
update-shape)
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
;;
delete)
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
local_arr=$(ids_to_json_array "$@")
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
;;
move)
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
;;
duplicate)
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
local_dx="${2:-0}"
local_dy="${3:-0}"
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
;;
group)
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
local_arr=$(ids_to_json_array "$@")
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
;;
ungroup)
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
;;
create-connector)
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
local_props="${3:-{}}"
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
;;
create-icon)
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
;;
add-page)
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
;;
get-page)
if [[ $# -ge 1 ]]; then
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
else
local_id
local_id=$(frame0_exec "page:get-current-page")
# Strip quotes from returned ID
local_id=$(echo "$local_id" | tr -d '"')
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
fi
;;
list-pages)
local_shapes="false"
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
;;
current-page)
frame0_exec "page:get-current-page"
;;
set-page)
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
;;
export)
local_page_id=""
local_format="image/png"
while [[ $# -gt 0 ]]; do
case "$1" in
--format) local_format="$2"; shift 2 ;;
*) local_page_id="$1"; shift ;;
esac
done
local_page_arg=""
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
;;
fit)
frame0_exec "view:fit-to-screen"
;;
--help|-h|help)
usage
;;
*)
echo "Unknown command: $CMD" >&2
usage
;;
esac
@@ -1,159 +0,0 @@
#!/usr/bin/env bash
# Batch export wireframes from JSON to PNG via Frame0.
#
# Finds all .json wireframe files under docs/design/wireframes/ and exports
# each to a matching .png. Skips files whose PNG is already newer than the
# JSON, unless --force is passed.
#
# Usage:
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
#
# Options:
# --dry-run Print manifest only, don't touch Frame0.
# --force Re-export even if PNG already exists and is up to date.
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
# --root DIR Wireframes root dir (default: docs/design/wireframes)
#
# Exit codes:
# 0 All exports succeeded (or nothing to do)
# 1 One or more exports failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
SYNC="$SCRIPT_DIR/frame0-sync.py"
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
DRY_RUN=false
FORCE=false
CATEGORY=""
WF_ROOT="$DEFAULT_ROOT"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--force) FORCE=true; shift ;;
--category) CATEGORY="$2"; shift 2 ;;
--root) WF_ROOT="$2"; shift 2 ;;
-h|--help)
sed -n '/^# /p' "$0" | sed 's/^# //'
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ ! -d "$WF_ROOT" ]]; then
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
exit 1
fi
# Collect JSON files, optionally filtered by category subdirectory
mapfile -t JSON_FILES < <(
if [[ -n "$CATEGORY" ]]; then
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
else
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
fi
)
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
echo "No wireframe JSON files found."
exit 0
fi
# Classify files into to-export and to-skip
TO_EXPORT=()
TO_SKIP=()
for json in "${JSON_FILES[@]}"; do
png="${json%.json}.png"
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
TO_EXPORT+=("$json")
else
TO_SKIP+=("$json")
fi
done
# Print manifest
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
echo ""
echo "Will export (${#TO_EXPORT[@]} files):"
for json in "${TO_EXPORT[@]}"; do
png="${json%.json}.png"
rel="${json#$REPO_ROOT/}"
if [[ ! -f "$png" ]]; then
status="new"
else
status="updated"
fi
printf " [%-7s] %s\n" "$status" "$rel"
done
else
echo ""
echo "Nothing to export (all PNGs up to date)."
fi
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
echo ""
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
for json in "${TO_SKIP[@]}"; do
rel="${json#$REPO_ROOT/}"
printf " [skip ] %s\n" "$rel"
done
fi
if $DRY_RUN; then
echo ""
echo "Dry run — no exports performed."
exit 0
fi
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
exit 0
fi
echo ""
PASSED=0
FAILED=0
FAILED_FILES=()
TOTAL=${#TO_EXPORT[@]}
IDX=0
for json in "${TO_EXPORT[@]}"; do
IDX=$((IDX + 1))
png="${json%.json}.png"
rel="${json#$REPO_ROOT/}"
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
rc=$?
if [[ $rc -eq 0 ]]; then
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
echo "ok $size"
PASSED=$((PASSED + 1))
else
echo "FAILED"
cat /tmp/frame0-batch-err.txt >&2
FAILED=$((FAILED + 1))
FAILED_FILES+=("$rel")
fi
done
echo ""
echo "$PASSED exported, $FAILED failed."
if [[ $FAILED -gt 0 ]]; then
echo ""
echo "Failed:" >&2
for f in "${FAILED_FILES[@]}"; do
echo " $f" >&2
done
exit 1
fi
exit 0
@@ -1,414 +0,0 @@
#!/usr/bin/env python3
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
Local JSON is source of truth. Frame0 is a renderer.
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
Usage:
frame0-sync.py push <wireframe.json> [--port PORT]
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error
DEFAULT_PORT = 58320
def api(port, command, args=None):
"""Execute a Frame0 API command. Returns the data field on success."""
url = f"http://localhost:{port}/execute_command"
payload = json.dumps({"command": command, "args": args or {}}).encode()
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as e:
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
sys.exit(1)
if not result.get("success"):
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
sys.exit(1)
return result.get("data")
# -- Mapping file management --------------------------------------------------
def mapping_path(wireframe_path):
"""Derive the idmap path from the wireframe JSON path."""
d = os.path.dirname(wireframe_path)
base = os.path.splitext(os.path.basename(wireframe_path))[0]
return os.path.join(d, f".{base}.idmap.json")
def load_mapping(wireframe_path):
p = mapping_path(wireframe_path)
if os.path.exists(p):
with open(p) as f:
return json.load(f)
return {"page_id": None, "shapes": {}, "connectors": {}}
def save_mapping(wireframe_path, mapping):
p = mapping_path(wireframe_path)
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
with open(p, "w") as f:
json.dump(mapping, f, indent=2)
f.write("\n")
# -- Push: local JSON -> Frame0 -----------------------------------------------
# Frame0 returns different type names from get vs what create accepts.
# Map internal types back to create-API types.
TYPE_TO_CREATE = {
"Box": "Rectangle",
"box": "Rectangle",
}
def topo_sort_shapes(shapes):
"""Sort shape IDs so parents come before children."""
order = []
visited = set()
def visit(sid):
if sid in visited:
return
visited.add(sid)
shape = shapes[sid]
parent = shape.get("parent")
if parent and parent in shapes:
visit(parent)
order.append(sid)
for sid in shapes:
visit(sid)
return order
def find_or_create_page(port, name, mapping):
"""Find existing page by mapping or name, or create a new one."""
# Try mapped page_id first
if mapping.get("page_id"):
try:
page = api(port, "page:get", {"pageId": mapping["page_id"]})
if page:
return mapping["page_id"]
except SystemExit:
pass # Page no longer exists, fall through
# Search by name in existing pages
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
if doc and "children" in doc:
for page in doc["children"]:
if page.get("name") == name:
return page["id"]
# Create new page
page = api(port, "page:add", {"pageProps": {"name": name}})
return page["id"]
def clear_page(port, page_id):
"""Delete all shapes on a page."""
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
if not page or "children" not in page:
return
shape_ids = [s["id"] for s in page["children"]]
if shape_ids:
api(port, "edit:delete", {"shapeIdArray": shape_ids})
def push(wireframe_path, port):
"""Push local wireframe JSON to Frame0."""
with open(wireframe_path) as f:
wireframe = json.load(f)
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
shapes = wireframe.get("shapes", {})
connectors = wireframe.get("connectors", {})
mapping = load_mapping(wireframe_path)
# Find or create page
page_id = find_or_create_page(port, name, mapping)
mapping["page_id"] = page_id
# Switch to page and clear it
api(port, "page:set-current-page", {"pageId": page_id})
clear_page(port, page_id)
# Reset ID mappings (shapes are recreated)
mapping["shapes"] = {}
mapping["connectors"] = {}
# Create shapes in dependency order
order = topo_sort_shapes(shapes)
for local_id in order:
shape = shapes[local_id]
raw_type = shape.get("type", "Rectangle")
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
parent_local = shape.get("parent")
# Build shapeProps from everything except meta fields
meta_keys = {"type", "parent"}
props = {k: v for k, v in shape.items() if k not in meta_keys}
# Set name to local_id if not explicitly set
if "name" not in props:
props["name"] = local_id
create_args = {
"type": shape_type,
"shapeProps": props,
"convertColors": True,
}
# Resolve parent ID
if parent_local and parent_local in mapping["shapes"]:
create_args["parentId"] = mapping["shapes"][parent_local]
f0_id = api(port, "shape:create-shape", create_args)
mapping["shapes"][local_id] = f0_id
# Create connectors
for local_id, conn in connectors.items():
tail_local = conn.get("tailId")
head_local = conn.get("headId")
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
continue
meta_keys = {"tailId", "headId"}
props = {k: v for k, v in conn.items() if k not in meta_keys}
if "name" not in props:
props["name"] = local_id
f0_id = api(port, "shape:create-connector", {
"tailId": mapping["shapes"][tail_local],
"headId": mapping["shapes"][head_local],
"shapeProps": props,
"convertColors": True,
})
mapping["connectors"][local_id] = f0_id
# Fit to screen
api(port, "view:fit-to-screen")
save_mapping(wireframe_path, mapping)
total = len(mapping["shapes"]) + len(mapping["connectors"])
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
# -- Pull: Frame0 -> local JSON -----------------------------------------------
def pull(page_ref, output_path, port):
"""Pull a Frame0 page into local wireframe JSON."""
# Resolve page_ref: could be an ID or a name
page_id = None
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
if doc and "children" in doc:
for page in doc["children"]:
if page["id"] == page_ref or page.get("name") == page_ref:
page_id = page["id"]
page_name = page.get("name", page_ref)
break
if not page_id:
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
sys.exit(1)
# Load existing mapping for reverse lookup
mapping = load_mapping(output_path)
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
# Get full page with shapes
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
shapes = {}
connectors = {}
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
auto_id_counter = [0]
def auto_id(f0_shape):
"""Generate a stable local ID from shape name or auto-number."""
# Prefer existing mapping
f0_id = f0_shape["id"]
if f0_id in reverse_map:
return reverse_map[f0_id]
# Use sanitized name
name = f0_shape.get("name", "")
if name:
sanitized = name.lower().replace(" ", "-").replace("_", "-")
if sanitized not in shapes:
return sanitized
# Fallback: auto-number
auto_id_counter[0] += 1
return f"s{auto_id_counter[0]:03d}"
def process_shape(f0_shape, parent_local_id=None):
f0_id = f0_shape["id"]
local_id = auto_id(f0_shape)
new_mapping["shapes"][local_id] = f0_id
# Extract shape properties — only strip structural keys that our
# ID mapping replaces. Everything else passes through as-is so the
# local JSON speaks Frame0's native vocabulary.
shape_type = f0_shape.get("type", "Box")
skip_keys = {"id", "type", "children", "pageId", "parentId"}
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
entry = {"type": shape_type}
if parent_local_id:
entry["parent"] = parent_local_id
entry.update(props)
# Remove name if it matches local_id (redundant)
if entry.get("name") == local_id:
del entry["name"]
shapes[local_id] = entry
# Process children recursively
for child in f0_shape.get("children", []):
child_type = child.get("type", "")
if child_type == "Connector":
process_connector(child)
else:
process_shape(child, local_id)
def process_connector(f0_conn):
f0_id = f0_conn["id"]
local_id = reverse_conn.get(f0_id)
if not local_id:
auto_id_counter[0] += 1
local_id = f"c{auto_id_counter[0]:03d}"
new_mapping["connectors"][local_id] = f0_id
tail_f0 = f0_conn.get("tail", {}).get("id")
head_f0 = f0_conn.get("head", {}).get("id")
entry = {}
if tail_f0:
# Will be resolved after all shapes are processed
entry["_tailF0"] = tail_f0
if head_f0:
entry["_headF0"] = head_f0
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
entry.update(props)
connectors[local_id] = entry
# Process all top-level shapes
for child in page.get("children", []):
child_type = child.get("type", "")
if child_type == "Connector":
process_connector(child)
else:
process_shape(child)
# Resolve connector references to local IDs
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
for conn in connectors.values():
tail_f0 = conn.pop("_tailF0", None)
head_f0 = conn.pop("_headF0", None)
if tail_f0 and tail_f0 in f0_to_local:
conn["tailId"] = f0_to_local[tail_f0]
if head_f0 and head_f0 in f0_to_local:
conn["headId"] = f0_to_local[head_f0]
wireframe = {"name": page_name}
if shapes:
wireframe["shapes"] = shapes
if connectors:
wireframe["connectors"] = connectors
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w") as f:
json.dump(wireframe, f, indent=2)
f.write("\n")
save_mapping(output_path, new_mapping)
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
# -- Export: push then export as image -----------------------------------------
def export_image(wireframe_path, output_path, port, fmt="image/png"):
"""Push wireframe to Frame0 and export the page as an image."""
import base64
# Push first to ensure Frame0 is up to date
push(wireframe_path, port)
mapping = load_mapping(wireframe_path)
page_id = mapping.get("page_id")
if not page_id:
print("ERROR: No page_id in mapping after push", file=sys.stderr)
sys.exit(1)
image_b64 = api(port, "file:export-image", {
"pageId": page_id,
"format": fmt,
"fillBackground": True,
})
image_bytes = base64.b64decode(image_b64)
with open(output_path, "wb") as f:
f.write(image_bytes)
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
# -- CLI -----------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
sub = parser.add_subparsers(dest="command")
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
p_push.add_argument("wireframe", help="Path to wireframe .json file")
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
p_pull.add_argument("page", help="Page ID or page name")
p_pull.add_argument("output", help="Output .json path")
p_export = sub.add_parser("export", help="Push and export as image")
p_export.add_argument("wireframe", help="Path to wireframe .json file")
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
p_export.add_argument("--format", default="image/png",
help="Export MIME type (default: image/png)")
args = parser.parse_args()
if args.command == "push":
push(args.wireframe, args.port)
elif args.command == "pull":
pull(args.page, args.output, args.port)
elif args.command == "export":
export_image(args.wireframe, args.output, args.port, args.format)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+31 -21
View File
@@ -30,17 +30,24 @@ This repo uses [Conventional Commits 1.0](https://www.conventionalcommits.org/en
Default to **one logical change per commit**, even when a lot of work lands in the tree at once. When there's a pile of uncommitted or untracked files:
1. **Read `git status` + `git diff` first** — never stage the whole tree blind.
2. **Group by concern**, not by file location. Typical concerns to separate:
2. **Triage: session vs pre-existing.** Separate changes you made in this
session from changes that were already dirty (or that a concurrent session
is producing — this worktree can host several Claude sessions at once).
Check the conversation history — if you didn't touch a file, it's not yours
to stage. After grouping your own commits, surface any remaining modified
or untracked files to the user and ask whether they belong in a commit.
Never silently skip or silently include changes that aren't yours.
3. **Group by concern**, not by file location. Typical concerns to separate:
- **Bookkeeping** — `.gitignore`, editor/IDE config, lockfiles, `go.sum` / `pubspec.lock` updates.
- **Documentation** — `README.md`, `CLAUDE.md`, ADRs under `docs/ADRs/`, design notes under `docs/`.
- **Documentation** — `README.md`, `CLAUDE.md`, D-records under `governance/`, design notes under `docs/`.
- **General-purpose tooling / skills** — things that aren't project-specific (reusable skills, shared scripts).
- **Project-specific conventions** — this repo's own rules.
- **Feature or subsystem** — one cohesive change per commit; a sidecar change and an app change for the same feature can land together, but two unrelated features should split.
- **Layer changes** — Flutter app, sidecar CLI, sidecar daemon, IPC server, pql wrapper, canvas driver, git panel — separate concerns; prefer separate commits when the changes are independent.
3. **Sequence the commits** so each one is cleanly scoped, but don't obsess about whether each intermediate commit "works" — for scaffolding PRs it's fine if the full picture only snaps together at the end.
4. **Prefer many small focused commits over one large mixed one** — a reviewer can read, revert, or cherry-pick a focused commit; they can't do any of those to a blob.
5. **Use `git add <specific paths>`** — never `git add -A` or `git add .` when splitting, or you'll sweep in the next commit's work by accident.
6. **Verify between commits** with `git status` and `git log -1` to confirm the split landed as intended.
- **Feature or subsystem** — one cohesive change per commit; a CLI change and an app change for the same feature can land together, but two unrelated features should split.
- **Layer changes** — Flutter app, `clide` CLI (`native/clide-cli/`), IPC server, pql wrapper, canvas driver, git panel — separate concerns; prefer separate commits when the changes are independent.
4. **Sequence the commits** so each one is cleanly scoped, but don't obsess about whether each intermediate commit "works" — for scaffolding PRs it's fine if the full picture only snaps together at the end.
5. **Prefer many small focused commits over one large mixed one** — a reviewer can read, revert, or cherry-pick a focused commit; they can't do any of those to a blob.
6. **Use `git add <specific paths>`** — never `git add -A` or `git add .` when splitting, or you'll sweep in the next commit's work by accident.
7. **Verify between commits** with `git status` and `git log -1` to confirm the split landed as intended.
Corollary: if a commit's subject line needs the word "and" to be accurate, it probably should have been two commits.
@@ -113,29 +120,32 @@ Co-Authored-By: Claude <noreply@anthropic.com>
The model-identifier variant produced by the Claude Code harness (e.g. `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`) is also accepted — don't rewrite it if the harness emits that form.
## HEREDOC discipline
## Message-file discipline
Pass commit messages via HEREDOC so multi-line formatting survives:
Pass commit messages via a message file so multi-line formatting survives
and the command stays inside the permission allowlist (command substitution
defeats the `git commit` prefix match and triggers permission prompts):
1. Write the full message to a file in `/tmp` (never inside `.git/` — that
directory is git's own state) using the Write tool.
2. Commit with it:
```bash
git commit -m "$(cat <<'EOF'
short imperative summary
Optional longer body explaining why this change was needed,
wrapped at about 72 characters.
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
git commit -F /tmp/commit-msg.txt
```
Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs between shells and quoting regimes and the trailer can end up in the wrong place.
Fallback only: the HEREDOC-in-substitution form
(`git commit -m "$(cat <<'EOF' … EOF)"`) works but prompts for permission —
use it only when writing a file is impossible. Never pass multi-line messages
via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs
between shells and quoting regimes and the trailer can end up in the wrong
place.
## What not to commit
- `.env` and any `*.env.local` — see `.gitignore`.
- `.claude/settings.local.json` — user-specific Claude Code settings, ignored.
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/` (Flutter output), `app/.dart_tool/` — gitignored.
- Build artefacts: `/build/` (Flutter output), `/.dart_tool/` — gitignored.
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
-104
View File
@@ -1,104 +0,0 @@
---
name: penpot-login
description: Connect to Penpot MCP - starts the MCP server, logs into penpot.schweitz.net, and installs/connects the plugin
allowed-tools: Read, Bash, mcp__chrome-devtools__new_page, mcp__chrome-devtools__navigate_page, mcp__chrome-devtools__take_snapshot, mcp__chrome-devtools__take_screenshot, mcp__chrome-devtools__click, mcp__chrome-devtools__fill, mcp__chrome-devtools__press_key, mcp__chrome-devtools__list_pages
---
# Penpot MCP Connection
This skill connects Claude to Penpot by:
1. Starting the Penpot MCP server (if not running)
2. Logging into penpot.schweitz.net via Authentik
3. Installing and connecting the Penpot MCP plugin
## Step 1: Start MCP Server
Check if the MCP server is running, start it if not:
```bash
# Check if server is running
if ! curl -s http://localhost:9880/mcp > /dev/null 2>&1; then
# Start the server in background
cd ~/Projects/penpot-mcp && ./start-server.sh &
sleep 3 # Wait for server to start
fi
```
Server endpoints when running:
- MCP Server: http://localhost:9880/mcp
- Plugin Server: http://localhost:9879/
- WebSocket: ws://localhost:4402
## Step 2: Login Credentials
Read credentials from the project root file `claude-authentik-credentials.md`:
!`cat claude-authentik-credentials.md`
## Step 3: Login Flow
1. **Navigate to Penpot**
- Use `mcp__chrome-devtools__new_page` to go to `https://penpot.schweitz.net`
2. **Check if already logged in**
- Take a snapshot
- If you see "Projects" heading, you're logged in - skip to Step 4
- If you see login page, continue with authentication
3. **Authenticate via Authentik** (if not logged in)
- Click the OpenID button to redirect to Authentik
- On auth.schweitz.net, fill username textbox
- Click "Log in" button
- Fill password textbox
- Click "Continue" button
- If redirected to Authentik user page instead of Penpot, navigate back to `https://penpot.schweitz.net`
## Step 4: Open Design File
1. **Navigate to the Clide project**
- Double-click on the TUI file to open the workspace
## Step 5: Install/Connect Plugin
1. **Open Plugins menu**
- Click the Plugins button (puzzle icon, keyboard shortcut: Cmd+Alt+P)
2. **Check if plugin is installed**
- If "Penpot MCP Plugin" appears under "INSTALLED PLUGINS", click OPEN
- Otherwise, install it first:
3. **Install plugin** (if not installed)
- Fill the plugin URL textbox with: `http://localhost:9879/manifest.json`
- Click INSTALL
- Click ALLOW on the permissions dialog
4. **Connect to MCP server**
- In the plugin UI, click "CONNECT TO MCP SERVER"
- Verify it shows "Connected to MCP server"
## Step 6: Configure Claude Code MCP
Add the Penpot MCP server to Claude Code (if not already configured):
```bash
claude mcp add penpot -t http http://localhost:9880/mcp
```
**Important**: Use HTTP transport (`-t http`) with the `/mcp` endpoint. Do NOT use SSE transport - it causes "Server not initialized" errors.
## Step 7: Verify Connection
After connecting, restart the MCP connection in Claude Code:
- Use `/mcp` command to reconnect the penpot server
- The Penpot MCP tools (execute_code, export_shape, etc.) will then be available
Test with:
```javascript
mcp__penpot__execute_code(code="return penpot.currentPage.name;")
```
## Important Notes
- Keep the Penpot plugin UI window open while using MCP tools
- The MCP server must be running for the plugin to connect
- If connection fails, check that the server is running on port 9880
- Use HTTP transport (`-t http`), NOT SSE transport
-203
View File
@@ -1,203 +0,0 @@
---
name: skill-create
description: >
Guidance for creating effective Claude Code skills (.skill packages).
Use when the user wants to create, build, design, or iterate on a skill —
including writing SKILL.md files, bundling scripts/references/assets,
initializing new skills, packaging skills, or improving existing ones.
Triggers on requests like "create a skill", "make a new skill",
"build a skill for X", "package this skill", or "improve my skill".
---
# Skill Creator
## About Skills
Skills are modular, self-contained packages that extend Claude's capabilities
by providing specialized knowledge, workflows, and tools. They transform Claude
from a general-purpose agent into a specialized agent equipped with procedural
knowledge that no model can fully possess.
### What Skills Provide
- **Specialized workflows** — Multi-step procedures for specific domains
- **Tool integrations** — Instructions for working with specific file formats or APIs
- **Domain expertise** — Company-specific knowledge, schemas, business logic
- **Bundled resources** — Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share it with everything else Claude
needs: system prompt, conversation history, other skills' metadata, and the
actual user request.
Default assumption: Claude is already very smart. Only add context Claude doesn't
already have. Challenge each piece of information: "Does Claude really need this
explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match specificity to the task's fragility and variability:
- **High freedom** (text-based instructions): Multiple approaches valid, decisions
depend on context, heuristics guide the approach.
- **Medium freedom** (pseudocode or scripts with parameters): Preferred pattern
exists, some variation acceptable, configuration affects behavior.
- **Low freedom** (specific scripts, few parameters): Operations are fragile and
error-prone, consistency is critical, specific sequence must be followed.
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific
guardrails (low freedom), while an open field allows many routes (high freedom).
## Anatomy of a Skill
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ ├── description: (required)
│ │ └── compatibility: (optional, rarely needed)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
### SKILL.md (required)
- **Frontmatter (YAML)**: `name` and `description` fields (required). Only these
are read by Claude to determine when the skill triggers — be clear and
comprehensive. The `compatibility` field is for environment requirements but
most skills don't need it.
- **Body (Markdown)**: Instructions and guidance. Only loaded AFTER the skill
triggers.
### Bundled Resources (optional)
**Scripts (`scripts/`)** — Executable code for tasks requiring deterministic
reliability or that are repeatedly rewritten.
**References (`references/`)** — Documentation loaded as needed into context.
Keep SKILL.md lean; move detailed reference material, schemas, and examples here.
If files are large (>10k words), include grep search patterns in SKILL.md.
**Assets (`assets/`)** — Files used in output, not loaded into context (templates,
images, icons, boilerplate code, fonts).
### What to NOT Include
Do NOT create extraneous files like README.md, INSTALLATION_GUIDE.md,
QUICK_REFERENCE.md, CHANGELOG.md, etc. The skill should only contain information
needed for an AI agent to do the job.
## Progressive Disclosure
Skills use a three-level loading system:
1. **Metadata** (name + description) — Always in context (~100 words)
2. **SKILL.md body** — When skill triggers (<5k words)
3. **Bundled resources** — As needed (unlimited; scripts can run without reading)
Keep SKILL.md body under 500 lines. Split content into separate files when
approaching this limit. Reference split files from SKILL.md with clear
descriptions of when to read them.
### Disclosure Patterns
**Pattern 1: High-level guide with references** — Keep overview in SKILL.md,
link to detail files loaded only when needed.
**Pattern 2: Domain-specific organization** — Organize content by domain
(e.g., `references/finance.md`, `references/sales.md`) so only relevant content
is loaded.
**Pattern 3: Conditional details** — Show basic content, link to advanced
content loaded only when the user needs those features.
Guidelines:
- Avoid deeply nested references — keep one level deep from SKILL.md
- Structure longer reference files with a table of contents at the top
## Skill Creation Process
Follow these steps in order, skipping only with clear reason:
### Step 1: Understand the Skill with Concrete Examples
Skip only when usage patterns are already clearly understood.
Ask the user for concrete examples of how the skill will be used:
- "What functionality should the skill support?"
- "Can you give some examples of how this skill would be used?"
- "What would a user say that should trigger this skill?"
Avoid overwhelming users — start with the most important questions.
### Step 2: Plan the Reusable Skill Contents
Analyze each example by considering how to execute from scratch and identifying
what scripts, references, and assets would help with repeated execution.
Establish a list of reusable resources: scripts, references, and assets.
### Step 3: Initialize the Skill
Create the skill directory manually:
```
mkdir -p <output-directory>/<skill-name>
```
Then create `SKILL.md` with frontmatter and body. Add `scripts/`, `references/`,
and `assets/` subdirectories only as needed.
Skip if iterating on an existing skill.
### Step 4: Edit the Skill
Remember the skill is for another Claude instance to use. Include beneficial,
non-obvious information.
For design patterns, consult:
- `references/workflows.md` — Sequential workflows and conditional logic
- `references/output-patterns.md` — Template and example patterns
**Implementation order:**
1. Start with reusable resources (`scripts/`, `references/`, `assets/`)
2. Test added scripts by running them
3. Delete unused example files from initialization
4. Update SKILL.md
**Writing guidelines:** Always use imperative/infinitive form.
**Frontmatter:**
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
The domain is the system/area the skill operates on, the action is what it does.
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
Multi-action wrappers (like `ticket`) can use the domain name alone.
The directory name must match the `name` field.
- `description`: Primary triggering mechanism. Include what the skill does AND
specific triggers/contexts. All "when to use" info goes here (not in body).
**Body:** Instructions for using the skill and its bundled resources.
### Step 5: Validate the Skill
Check the skill manually:
- Frontmatter has `name` and `description`
- SKILL.md body is under 500 lines
- No extraneous files (README.md, CHANGELOG.md, etc.)
- Scripts are executable and tested
- References are referenced from SKILL.md
### Step 6: Iterate
After real usage, notice struggles or inefficiencies, identify needed updates,
implement changes, and test again.
+6 -3
View File
@@ -6,9 +6,10 @@ description: >
lifecycle, theme loading, or native platform code. Also use when
the user says "run testmode", "test the app", "smoke test", or
"verify the build". Triggers on changes to: lib/kernel/src/toolchain.dart,
lib/src/daemon/dispatcher.dart, lib/src/ipc/, lib/extension/,
lib/kernel/src/theme/, lib/builtin/*/src/extension.dart,
linux/CMakeLists.txt, macos/, Makefile (run targets), lib/main.dart.
lib/src/daemon/dispatcher.dart, lib/src/ipc/, lib/src/pty/,
lib/builtin/terminal/, lib/extension/, lib/kernel/src/theme/,
lib/builtin/*/src/extension.dart, linux/CMakeLists.txt, macos/,
Makefile (run targets), lib/main.dart.
---
# Testmode harness
@@ -26,6 +27,7 @@ make run-testmode # all categories, 60s timeout
make run-testmode TESTMODE_CATEGORY=toolchain
make run-testmode TESTMODE_CATEGORY=ipc
make run-testmode TESTMODE_CATEGORY=extensions
make run-testmode TESTMODE_CATEGORY=terminal
make run-testmode TESTMODE_TIMEOUT=120 # longer timeout for slow builds
```
@@ -47,6 +49,7 @@ Exit code is non-zero when any test fails. The Makefile verifies
| IPC envelope, dispatcher, schema | `ipc` | Round-trip + error contract |
| Extension manifest, activate, contributions | `extensions` | Register + activate lifecycle |
| Theme YAML, loader, palette | `extensions` | Theme parse is in this category |
| PTY spawning, terminal pane, xterm wiring | `terminal` | Real PTY spawn + I/O round-trip |
| Platform config (CMakeLists, pbxproj, Makefile) | `all` | Full rebuild validates everything |
| Any doubt | `all` | ~30s, cheap insurance |
File diff suppressed because it is too large Load Diff
+17 -23
View File
@@ -3,27 +3,22 @@
Mirrors `assets/fonts/phosphor/codepoints.csv` (the full bundled glyph set)
into a readable, greppable markdown table at
`.claude/skills/ui-design/references/phosphor-glyphs.md`, flagging which
glyphs clide already defines in `lib/widgets/src/icons/phosphor.dart`.
`.claude/skills/ui-design/references/phosphor-glyphs.md`.
That table is also the source of truth for the `kPhosphorGlyphs` map after
regenerating it, re-run `dart run tool/gen_phosphor_glyphs.dart` so
`lib/widgets/src/icons/phosphor_glyphs.g.dart` stays in sync.
Run from the repo root: python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py
"""
import csv
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[4]
CSV = ROOT / "assets/fonts/phosphor/codepoints.csv"
DART = ROOT / "lib/widgets/src/icons/phosphor.dart"
OUT = ROOT / ".claude/skills/ui-design/references/phosphor-glyphs.md"
# Codepoint -> camelCase accessor already defined in PhosphorIcons.
defined = {}
for m in re.finditer(r"static const (\w+) = PhosphorIconPainter\((0x[0-9a-fA-F]+)\)", DART.read_text()):
defined[int(m.group(2), 16)] = m.group(1)
rows = list(csv.DictReader(CSV.open()))
n_def = sum(1 for r in rows if int(r["codepoint"], 16) in defined)
lines = [
"---",
@@ -37,22 +32,21 @@ lines = [
f"All **{len(rows)}** glyphs bundled in clide's Phosphor font "
"(`assets/fonts/phosphor/`, MIT). Generated from "
"`assets/fonts/phosphor/codepoints.csv` — **do not hand-edit**; regenerate with "
"`python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py`.",
"`python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py`, then re-run "
"`dart run tool/gen_phosphor_glyphs.dart` (this table is the source for the "
"generated `kPhosphorGlyphs` map).",
"",
f"The **In clide** column flags the **{n_def}** glyphs already wired into "
"`PhosphorIcons` (`lib/widgets/src/icons/phosphor.dart`) — reach for those first. "
"To use any other glyph, add a one-line `static const` to that class with the "
"codepoint below, then `ClideIcon(PhosphorIcons.<name>, size: 13)`. Keep additions "
"to icons we actually use — don't bulk-import.",
"Every glyph is available in code via `PhosphorIcons.byName('<kebab-name>')` "
"(T-314) — e.g. `ClideIcon(PhosphorIcons.byName('folder'), size: 13)`. Raw "
"codepoints never appear in feature code; they live only in the generated map. "
"An unknown name renders the `placeholder` glyph, and `phosphor_glyphs_test` "
"asserts every `byName('')` literal in `lib/` resolves.",
"",
"| Codepoint | Name (kebab) | Pascal | In clide |",
"|---|---|---|---|",
"| Codepoint | Name (kebab) | Pascal |",
"|---|---|---|",
]
for r in rows:
cp = r["codepoint"]
accessor = defined.get(int(cp, 16))
in_clide = f"`PhosphorIcons.{accessor}`" if accessor else ""
lines.append(f"| `{cp}` | {r['name']} | {r['pascal_name']} | {in_clide} |")
lines.append(f"| `{r['codepoint']}` | {r['name']} | {r['pascal_name']} |")
OUT.write_text("\n".join(lines) + "\n")
print(f"wrote {OUT.relative_to(ROOT)}{len(rows)} glyphs, {n_def} already defined")
print(f"wrote {OUT.relative_to(ROOT)}{len(rows)} glyphs")
+22 -22
View File
@@ -4,7 +4,8 @@ description: >
Surface the best batch of tickets to pick up next from pql. Walks the
initiative/epic tree, filters to unblocked work, refines context via
parallel agents (or `pql ticket refine` for empty descriptions), and
optionally activates the batch on a fresh branch. Use when the user
optionally activates the batch (directly on main — no topic branch by
default). Use when the user
says "what's next", "next batch", "pick up work", or invokes
/whats-next. NOT triggered by "what should we work on" in a design
context — that's a discussion, not a batch selection.
@@ -18,7 +19,8 @@ Dependency-driven batch selection against pql. Three steps:
batch selection → refinement review → batch activation.
Pql is the single source of truth for tickets and decisions in this repo
(see [pql skill](../pql/SKILL.md) and [`decisions/README.md`](../../../decisions/README.md)). Always run from the repo root.
(see the pql skill — user scope, installed via `pql init` — and
[`governance/README.md`](../../../governance/README.md)). Always run from the repo root.
## Step 0: Sync state
@@ -158,8 +160,8 @@ Agent({
Your job:
1. Run `pql decisions show <decision_ref> --with-refs --pretty` and
read the linked D/Q-record in decisions/<domain>.md.
2. Grep decisions/questions-*.md for related Q-records.
read the linked D/Q-record in governance/decisions/<domain>.md.
2. Grep governance/questions/<domain>.md for related Q-records.
3. Verify referenced files, classes, and APIs actually exist in the
current tree (Read/Grep). Flag dangling references.
4. Cross-check against CLAUDE.md guardrails (single process, CLI-first,
@@ -217,24 +219,22 @@ Batch transition (comma-separated IDs):
pql ticket status T-1,T-2,T-3 in_progress
```
**Then persist it.** Ticket mutations (status here, and any `ticket new` in
Step 2) land only in the gitignored `.pql/pql.db`. The post-checkout/post-merge
hooks rebuild that DB from the committed changelog on every branch switch — so
un-exported changes vanish silently the next time anyone switches branches. After
creating or transitioning tickets, always:
```bash
pql plan export # regenerates .pql/changelog/*.sql
git add .pql/changelog && git commit # durable; survives rebuild
```
See the [pql skill](../pql/SKILL.md#versioning-planning-state--data-loss-footgun-read-this).
**Then make sure it persists.** Ticket mutations (status here, and any
`ticket new` in Step 2) land only in the gitignored `.pql/pql.db`. The
pre-commit hook runs `pql plan export --stage` automatically — the changelog
is exported and staged on every commit, so never hand-run the export or
`git add .pql/changelog`. The rule is simpler: the turn must land at least
one commit (through the git-commit skill). A ticket-only turn with no commit
leaves the mutations in `pql.db` only, and the post-checkout/post-merge hooks
rebuild that DB from the committed changelog on the next branch switch —
silently dropping them. See the pql skill's "Versioning planning state"
section for the full mechanics.
### 3b. Branch? Default no.
Solo-dev flow on this repo — work lands directly on `main` (see recent
`git log`). Don't create a topic branch unless the user explicitly asks.
If they do, plain `git checkout -b` is fine; there is no `gh` CLI.
If they do, plain `git checkout -b` is fine.
### 3c. Spawn implementation agents (optional)
@@ -263,13 +263,13 @@ End with a tight summary:
## Anti-patterns
- Don't skip Step 0 — stale `pql.db` makes the rest of the skill lie.
- Don't leave ticket changes un-exported — `pql.db` is gitignored and the
post-checkout/post-merge hooks rebuild it from the committed changelog, so a
branch switch silently drops un-exported tickets. Always `pql plan export` +
commit `.pql/changelog/` after mutating tickets (Step 3a).
- Don't hand-export or hand-stage `.pql/changelog/` — the pre-commit hook does
both on every commit. The real footgun is a turn that mutates tickets but
never commits: `pql.db` is gitignored and gets rebuilt from the committed
changelog on branch switch, silently dropping un-committed mutations. Land
at least one commit per ticket-mutating turn (Step 3a).
- Don't activate a batch the user hasn't confirmed.
- Don't spawn refinement agents for tickets that have no description — use
`pql ticket refine` instead; it's cheaper and writes back through the
proper channel.
- Don't reach for `gh` — this system doesn't have it. Plain `git` only.
- Don't `cd` into subdirectories — run everything from the repo root.
+111
View File
@@ -0,0 +1,111 @@
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', NULL, '2026-07-02 06:35:03', '2026-07-02 06:35:03.555', '2026-07-02 06:35:03.555', NULL, '33a6fd1c81f2e1142d15ce5791ce3b24', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
PROGRESS (2026-07-02, session 2): app-integration phase 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).', NULL, '2026-07-02 08:33:27', '2026-07-02 08:33:27.519', '2026-07-02 08:33:27.519', NULL, '65090e3a71c9f5f49b8070a112216210', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'status', 'backlog', 'done', NULL, '2026-07-02 09:55:52', '2026-07-02 09:55:52.114', '2026-07-02 09:55:52.114', NULL, 'a417eec1fa067a31e46b21578a6228fb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
PROGRESS (2026-07-02, session 2): app-integration phase 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
PROGRESS (2026-07-02, session 2): app-integration phase 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).
COMPLETE (2026-07-02, session 2). All scope delivered + tested (56 graph tests, make test green, real-boot verified via testmode). Slices this session: GraphController (3b67725b), pane shell (6fae1bc4), extension+i18n registration (a5acb6ac), pan+zoom (e6556401), testmode coverage (46d9b403), filter model (f859173b), filter bar (aabec87d). Final feature: force-directed vault graph in the context panel (graph.view, D-47) hover-highlight, click-to-open, scroll-zoom, drag-pan, and filters (path glob re-queries pql; depth-from-active local graph; tri-state tag include/exclude). Controller uses pql.meta (outlinks+tags in one call) and strips #heading fragments so heading links connect. NOTE: pql data load is 1 files + N meta calls (sequential); batching is a future optimisation if large vaults feel slow.', NULL, '2026-07-02 19:47:37', '2026-07-02 19:47:37.694', '2026-07-02 19:47:37.694', NULL, 'a9f6b8b38858b687f106d1bda236821d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'status', 'in_progress', 'done', NULL, '2026-07-02 19:47:44', '2026-07-02 19:47:44.357', '2026-07-02 19:47:44.357', NULL, 'c8bf520d62290050e6d0feb4a9a6a68d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', NULL, '2026-07-02 20:27:03', '2026-07-02 20:27:03.493', '2026-07-02 20:27:03.493', NULL, '7a661d8cc999f526e26cb1a14b7839c4', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'description', NULL, 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.', NULL, '2026-07-02 20:31:02', '2026-07-02 20:31:02.807', '2026-07-02 20:31:02.807', NULL, '5030849d297f6312073802a0641a2594', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'status', 'backlog', 'in_progress', NULL, '2026-07-02 20:35:12', '2026-07-02 20:35:12.739', '2026-07-02 20:35:12.739', NULL, 'd971e23e05c7547672b6989ca0a98434', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'description', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.', NULL, '2026-07-02 20:39:06', '2026-07-02 20:39:06.684', '2026-07-02 20:39:06.684', NULL, '02ea6e2dbb60929aed687275f5329a4f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'description', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.
SCOPE (do not narrow): the local-command-stdout rendering defect above is ONE symptom, not the whole ticket. The core problem is that after switching model or effort the user has no reliable, well-placed signal that it took effect so this stays a broad investigate-and-improve of the switch/toggle trust UX, with the render bug as just the first concrete instance. Other symptoms/questions to investigate (non-exhaustive): does the control itself (Config tab picker / effort control / any menu) visibly update to the NEW active value after a switch, or does it look unchanged? is there ANY acknowledgement at the point of interaction, or only (mangled) output buried in the transcript far from where the user clicked? do model vs effort behave consistently, or differently? can clide''s shown ''active'' value drift from what actually took effect (e.g. a switch that silently no-ops)? is the confirmation positioned where the eye already is? The deliverable is: across all these controls, a consistent and unmistakable ''this is now active'' fixing the render path is necessary but not sufficient. Enumerate the full symptom set during investigation before designing the fix.', NULL, '2026-07-02 20:41:13', '2026-07-02 20:41:13.402', '2026-07-02 20:41:13.402', NULL, '1c7d99c842cc857d5978d91e5b23ab8b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).', NULL, '2026-07-02 21:22:59', '2026-07-02 21:22:59.395', '2026-07-02 21:22:59.395', NULL, '6b17245b0d3d39a0d61bc90a2a0a034b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) deferred, flagged now.', NULL, '2026-07-03 20:20:00', '2026-07-03 20:20:00.827', '2026-07-03 20:20:00.827', NULL, '541312e6b1f559f60934ad1a1b784d76', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) deferred, flagged now.', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) deferred, flagged now.
PROGRESS (2026-07-05): REACHABLE-INTEGRATION PHASE COMPLETE (e2e305de). CanvasExtension is a real workspace pane: app-scoped MultitabController on the extension (diff/T-233 pattern) with real per-document sub-tabs (MultitabPane keepAlive, per refinement decision), CanvasPaneHost + CanvasDocumentTab (files.read -> CanvasDoc.parse -> CanvasView; loading/error states), routing wired (openWorkspaceFile .canvas branch; ui.open ''canvas'' reader so clide ui open canvas <path> works; fileGlobs left dead as decided), en_us+nl_nl catalogs, json_canvas doc header corrected. Tests: extension lifecycle (7), pane-host widget (6), file_open routing (5, new file), ui_command canvas case; testmode extension roster now includes builtin.canvas (26/26 real-boot pass). REMAINING (edit phase, fresh session): node drag + resize, add note/text/edge affordances, persist CanvasDoc back to disk encode() exists; CanvasView needs an onChanged/mutation path (refinement note item 5).', NULL, '2026-07-05 07:29:15', '2026-07-05 07:29:15.452', '2026-07-05 07:29:15.452', NULL, '0d0ff6c05b550d4a85296eb38adc830f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'status', 'backlog', 'in_progress', NULL, '2026-07-05 09:52:54', '2026-07-05 09:52:54.013', '2026-07-05 09:52:54.013', NULL, '59efe4bd0a098bfea3f146af21b0caf3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'status', 'in_progress', 'done', NULL, '2026-07-05 10:54:23', '2026-07-05 10:54:23.532', '2026-07-05 10:54:23.532', NULL, '941d08bde0cc4479860e015aded3d664', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'status', 'backlog', 'in_progress', NULL, '2026-07-13 15:17:17', '2026-07-13 15:17:17.537', '2026-07-13 15:17:17.537', NULL, 'fb951233b5fc242889cae1156dc1d0d8', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'description', NULL, 'Implements D-106 (FR-1 + worktree addendum). Storage: user-scope key app.env.pathPrepend.<fnv1a64Hex(canonicalWorkspaceKey(presetRoot))> in ~/.config/clide/settings.yaml — never committed; presetRootFor() resolves a linked-worktree workspace (gitdir: pointer file, e.g. .worktrees/<name>) to the main repo root so all worktrees share the repo''s preset.
Injection (prepend, dedup, next-spawn semantics): PTY panes via PaneRegistry.pathForSpawn hook (main.dart wires the preset-aware closure); hosted Claude via agentEnvDelta(prependDirs:) now exports PATH whenever a preset exists even if clide is already on PATH (previous gap) threaded orchestrator.pathPresetFor claude extension ctx.settings. Toolchain/supporter-binary resolution deliberately preset-blind (T-98/D-92 fence).
CLI (D-6): one cmd id env.path clide env path list|set|add|remove|clear|capture; Flutter-free handler lib/src/daemon/env_path_commands.dart over an injected PathPresetStore port (adapter in main.dart); mutations publish on envPathChannel; capture diffs loginShellPathOrNull() (new shell_env getter) vs process PATH. UI: Settings Tools Workspace PATH section, custom control (ordered list, add/remove/reorder, missing-dir warning, worktree note, capture suggestions) in builtin.tools-settings; en+nl catalogs.
.worktrees/ added to IgnoreSet.builtin() and the project-init gitignore scaffold. Tests: path_preset_test (pure fns + real worktree layout), env_path_commands_test (all verbs), agent_bootstrap prependDirs cases, PaneRegistry pathForSpawn PTY test, widget tests for the control incl. worktree key sharing + capture. make test (4048) + make test-a11y green.', NULL, '2026-07-13 15:39:31', '2026-07-13 15:39:31.309', '2026-07-13 15:39:31.309', NULL, '44007d87d28b52050716cd913009d341', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'status', 'in_progress', 'done', NULL, '2026-07-13 16:48:56', '2026-07-13 16:48:56.833', '2026-07-13 16:48:56.833', NULL, 'd6c02324e60b8d412840b1ffd600a13f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNTF7KE5Y497PQC7SCFGR1KC', 'status', 'backlog', 'done', NULL, '2026-07-13 20:49:17', '2026-07-13 20:49:17.282', '2026-07-13 20:49:17.282', NULL, 'd2d3b437c1063f835e5b34c0cd996f9d', 2) ON CONFLICT(hash) DO NOTHING;
+6
View File
@@ -0,0 +1,6 @@
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'T-507', '2026-07-02 09:41:05.241', '2026-07-02 09:41:05.241', NULL, 'd9dec9b9ed45db65467318f53eb05157', 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 ('06FJ4D1M6ESSSQ1ZERTH4V9YN4', 'T-508', '2026-07-02 09:41:14.425', '2026-07-02 09:41:14.425', NULL, '5389a964251dc22408c41ceac6954f4d', 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 ('06FJ91RYFXBJH99HJ18QTHY2B0', 'T-509', '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.783', NULL, 'ea835aca1960e1bee3d0710be19b8bba', 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 ('06FK3CDZQD2JM95ZQY5MH53PWC', 'T-510', '2026-07-05 09:52:40.384', '2026-07-05 09:52:40.384', NULL, '6acffc225f49858f782c8c5e801e1c24', 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 ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'T-511', '2026-07-13 15:17:10.276', '2026-07-13 15:17:10.276', NULL, 'ffc35cbc7ffe3577e9f103ce6b411857', 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 ('06FNTF7KE5Y497PQC7SCFGR1KC', 'T-512', '2026-07-13 20:48:23.414', '2026-07-13 20:48:23.414', NULL, '90218f8881c60ea87ee29cdfc4e4bdb3', 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);
+101
View File
@@ -0,0 +1,101 @@
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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 06:35:03.555', NULL, 'a46ad64b03bc1b4126e9882ed7b81a3a', 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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
PROGRESS (2026-07-02, session 2): app-integration phase 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 08:33:27.518', NULL, 'dc254ed9b53362c45d11ac23f600b57d', 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 ('06FJ4D0GAXM1FN42PJKG7FE34G', 'task', NULL, 'Skill cleanup: apply 2026-07-02 estate-review fixes to clide repo + user scope', 'Apply verified findings from the estate-wide skill review: delete commit/penpot-login/clean-house(project copy) skills; centralize d2-diagram/frame0-wireframe/skill-create to user scope de-contaminated; repoint whats-next (governance/, gh, dead links, step 3a); fix git-commit heredoc guidance + stale sidecar/ADR refs; fix clide skill dock slot + description trim; testmode terminal category; ui-design phosphor glyph reference post-T-314; rewrite skills README index.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 09:41:05.239', '2026-07-02 09:41:05.239', NULL, 'c5ddebbb4ffe8611329732087dd1cebb', 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 ('06FJ4D1M6ESSSQ1ZERTH4V9YN4', 'task', NULL, 'Distribute the clide skill per-workspace with an install marker', 'Estate review 2026-07-02: the clide skill is repo-agnostic and hand-copied into other repos (settled-reach), drifting. Long-term story: clide installs the skill into each workspace''s .claude/skills/ with a .pql-install.json-style marker (version + sha256) so staleness is detectable, following pql''s clean-house precedent. Until then the canonical copy lives in the clide repo.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 09:41:14.420', '2026-07-02 09:41:14.420', NULL, '698b529e4ea8e4417ddb3b9846b30c13', 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 ('06FJ4D0GAXM1FN42PJKG7FE34G', 'task', NULL, 'Skill cleanup: apply 2026-07-02 estate-review fixes to clide repo + user scope', 'Apply verified findings from the estate-wide skill review: delete commit/penpot-login/clean-house(project copy) skills; centralize d2-diagram/frame0-wireframe/skill-create to user scope de-contaminated; repoint whats-next (governance/, gh, dead links, step 3a); fix git-commit heredoc guidance + stale sidecar/ADR refs; fix clide skill dock slot + description trim; testmode terminal category; ui-design phosphor glyph reference post-T-314; rewrite skills README index.', 'done', 'medium', NULL, NULL, NULL, '2026-07-02 09:41:05.239', '2026-07-02 09:55:52.109', NULL, '7470547f02b1269981faefe053aa4ae3', 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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
PROGRESS (2026-07-02, session 2): app-integration phase 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).
COMPLETE (2026-07-02, session 2). All scope delivered + tested (56 graph tests, make test green, real-boot verified via testmode). Slices this session: GraphController (3b67725b), pane shell (6fae1bc4), extension+i18n registration (a5acb6ac), pan+zoom (e6556401), testmode coverage (46d9b403), filter model (f859173b), filter bar (aabec87d). Final feature: force-directed vault graph in the context panel (graph.view, D-47) hover-highlight, click-to-open, scroll-zoom, drag-pan, and filters (path glob re-queries pql; depth-from-active local graph; tri-state tag include/exclude). Controller uses pql.meta (outlinks+tags in one call) and strips #heading fragments so heading links connect. NOTE: pql data load is 1 files + N meta calls (sequential); batching is a future optimisation if large vaults feel slow.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 19:47:37.694', NULL, '6ef9144fd880038d5db1d318925a9afd', 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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking only its solver core is done.
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver force_layout.dart (bcef3a6f). (2) link-graph model vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
PROGRESS (2026-07-02, session 2): app-integration phase 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).
COMPLETE (2026-07-02, session 2). All scope delivered + tested (56 graph tests, make test green, real-boot verified via testmode). Slices this session: GraphController (3b67725b), pane shell (6fae1bc4), extension+i18n registration (a5acb6ac), pan+zoom (e6556401), testmode coverage (46d9b403), filter model (f859173b), filter bar (aabec87d). Final feature: force-directed vault graph in the context panel (graph.view, D-47) hover-highlight, click-to-open, scroll-zoom, drag-pan, and filters (path glob re-queries pql; depth-from-active local graph; tri-state tag include/exclude). Controller uses pql.meta (outlinks+tags in one call) and strips #heading fragments so heading links connect. NOTE: pql data load is 1 files + N meta calls (sequential); batching is a future optimisation if large vaults feel slow.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 19:47:44.357', NULL, '4d534c5ebbc8245ac9cdf1c41ddfca06', 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-02 20:27:03.493', NULL, '163772c5b568c9fb0b69715648555c30', 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 ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.783', NULL, '931821336de8bd9ca5e77cd62f3ca1d3', 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 ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.806', NULL, 'b4e300bee467ba284a30772cb7a186bb', 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-02 20:35:12.739', NULL, '3e2c84564d3b6cf59f8c8f0e8af1b0b5', 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 ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:39:06.684', NULL, '8678946b438ae5cbdff677bb73949374', 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 ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.
SCOPE (do not narrow): the local-command-stdout rendering defect above is ONE symptom, not the whole ticket. The core problem is that after switching model or effort the user has no reliable, well-placed signal that it took effect so this stays a broad investigate-and-improve of the switch/toggle trust UX, with the render bug as just the first concrete instance. Other symptoms/questions to investigate (non-exhaustive): does the control itself (Config tab picker / effort control / any menu) visibly update to the NEW active value after a switch, or does it look unchanged? is there ANY acknowledgement at the point of interaction, or only (mangled) output buried in the transcript far from where the user clicked? do model vs effort behave consistently, or differently? can clide''s shown ''active'' value drift from what actually took effect (e.g. a switch that silently no-ops)? is the confirmation positioned where the eye already is? The deliverable is: across all these controls, a consistent and unmistakable ''this is now active'' fixing the render path is necessary but not sufficient. Enumerate the full symptom set during investigation before designing the fix.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:41:13.402', NULL, '9c7b3632bd0846533a1b65cc12b8145a', 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-02 21:22:59.395', NULL, '6ba2a2c1892dbf56c582f83bd9753314', 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) deferred, flagged now.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-03 20:20:00.827', NULL, '34eeab12879a55e77e6c9841a442cf27', 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
PROGRESS (2026-07-02, cont.): third foundation slice landed interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) deferred, flagged now.
PROGRESS (2026-07-05): REACHABLE-INTEGRATION PHASE COMPLETE (e2e305de). CanvasExtension is a real workspace pane: app-scoped MultitabController on the extension (diff/T-233 pattern) with real per-document sub-tabs (MultitabPane keepAlive, per refinement decision), CanvasPaneHost + CanvasDocumentTab (files.read -> CanvasDoc.parse -> CanvasView; loading/error states), routing wired (openWorkspaceFile .canvas branch; ui.open ''canvas'' reader so clide ui open canvas <path> works; fileGlobs left dead as decided), en_us+nl_nl catalogs, json_canvas doc header corrected. Tests: extension lifecycle (7), pane-host widget (6), file_open routing (5, new file), ui_command canvas case; testmode extension roster now includes builtin.canvas (26/26 real-boot pass). REMAINING (edit phase, fresh session): node drag + resize, add note/text/edge affordances, persist CanvasDoc back to disk encode() exists; CanvasView needs an onChanged/mutation path (refinement note item 5).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-05 07:29:15.447', NULL, '1be84ba4d4357171ef329f39b601f4e1', 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 ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 09:52:40.379', NULL, 'ec1eed07c9a69b4edbf3f4ffe671f696', 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 ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 09:52:54.013', NULL, '56204043cd686ca0fcae06db735b72ef', 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 ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'done', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 10:54:23.532', NULL, '143695c5eefa42dab2c05edcb06a4427', 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 ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', NULL, 'backlog', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 15:17:10.275', NULL, 'eb6abfb644234fe0c73b4785efcdad16', 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 ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', NULL, 'in_progress', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 15:17:17.532', NULL, '6e4b7347d0e854ea4c5fe4b394c61412', 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 ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', 'Implements D-106 (FR-1 + worktree addendum). Storage: user-scope key app.env.pathPrepend.<fnv1a64Hex(canonicalWorkspaceKey(presetRoot))> in ~/.config/clide/settings.yaml — never committed; presetRootFor() resolves a linked-worktree workspace (gitdir: pointer file, e.g. .worktrees/<name>) to the main repo root so all worktrees share the repo''s preset.
Injection (prepend, dedup, next-spawn semantics): PTY panes via PaneRegistry.pathForSpawn hook (main.dart wires the preset-aware closure); hosted Claude via agentEnvDelta(prependDirs:) now exports PATH whenever a preset exists even if clide is already on PATH (previous gap) threaded orchestrator.pathPresetFor claude extension ctx.settings. Toolchain/supporter-binary resolution deliberately preset-blind (T-98/D-92 fence).
CLI (D-6): one cmd id env.path clide env path list|set|add|remove|clear|capture; Flutter-free handler lib/src/daemon/env_path_commands.dart over an injected PathPresetStore port (adapter in main.dart); mutations publish on envPathChannel; capture diffs loginShellPathOrNull() (new shell_env getter) vs process PATH. UI: Settings Tools Workspace PATH section, custom control (ordered list, add/remove/reorder, missing-dir warning, worktree note, capture suggestions) in builtin.tools-settings; en+nl catalogs.
.worktrees/ added to IgnoreSet.builtin() and the project-init gitignore scaffold. Tests: path_preset_test (pure fns + real worktree layout), env_path_commands_test (all verbs), agent_bootstrap prependDirs cases, PaneRegistry pathForSpawn PTY test, widget tests for the control incl. worktree key sharing + capture. make test (4048) + make test-a11y green.', 'in_progress', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 15:39:31.309', NULL, 'c5a978f32c7497e478c471069ed20b7c', 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 ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', 'Implements D-106 (FR-1 + worktree addendum). Storage: user-scope key app.env.pathPrepend.<fnv1a64Hex(canonicalWorkspaceKey(presetRoot))> in ~/.config/clide/settings.yaml — never committed; presetRootFor() resolves a linked-worktree workspace (gitdir: pointer file, e.g. .worktrees/<name>) to the main repo root so all worktrees share the repo''s preset.
Injection (prepend, dedup, next-spawn semantics): PTY panes via PaneRegistry.pathForSpawn hook (main.dart wires the preset-aware closure); hosted Claude via agentEnvDelta(prependDirs:) now exports PATH whenever a preset exists even if clide is already on PATH (previous gap) threaded orchestrator.pathPresetFor claude extension ctx.settings. Toolchain/supporter-binary resolution deliberately preset-blind (T-98/D-92 fence).
CLI (D-6): one cmd id env.path clide env path list|set|add|remove|clear|capture; Flutter-free handler lib/src/daemon/env_path_commands.dart over an injected PathPresetStore port (adapter in main.dart); mutations publish on envPathChannel; capture diffs loginShellPathOrNull() (new shell_env getter) vs process PATH. UI: Settings Tools Workspace PATH section, custom control (ordered list, add/remove/reorder, missing-dir warning, worktree note, capture suggestions) in builtin.tools-settings; en+nl catalogs.
.worktrees/ added to IgnoreSet.builtin() and the project-init gitignore scaffold. Tests: path_preset_test (pure fns + real worktree layout), env_path_commands_test (all verbs), agent_bootstrap prependDirs cases, PaneRegistry pathForSpawn PTY test, widget tests for the control incl. worktree key sharing + capture. make test (4048) + make test-a11y green.', 'done', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 16:48:56.833', NULL, '09c6b262b3c0ecfd2f621d27f2cc5eb2', 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 ('06FNTF7KE5Y497PQC7SCFGR1KC', 'task', NULL, 'Lead the clide skill description with clide capabilities + env mechanics (FR-2)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-07-13 20:48:23.409', '2026-07-13 20:48:23.409', NULL, '28edb12dfa1f2bf75d108f6cf9465ea5', 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 ('06FNTF7KE5Y497PQC7SCFGR1KC', 'task', NULL, 'Lead the clide skill description with clide capabilities + env mechanics (FR-2)', NULL, 'done', 'low', NULL, NULL, NULL, '2026-07-13 20:48:23.409', '2026-07-13 20:49:17.282', NULL, 'b7eaec1d037d1c36cf54fac07b3b2dd4', 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);
+37
View File
@@ -22,6 +22,43 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
## [2.10.0] — 2026-07-13
### Added
- **Canvas pane.** `.canvas` (Obsidian JSONCanvas) files now open in an
interactive workspace pane — each document a sub-tab, with pan, zoom, and
node selection. Routed from the file tree, quick-open, and
`clide ui open canvas <path>`. (T-322)
- **Vault graph view.** A force-directed link graph of the whole vault in the
context panel — notes are nodes, wikilinks edges. Hover highlights a note's
neighbourhood; click opens it. Scroll to zoom, drag to pan; filter by path
glob, tag include/exclude, or depth from the active note. (T-323)
- **Per-workspace PATH preset.** Directories prepended to the PATH of every
shell clide spawns for a repo — Claude sessions and terminal panes. Settings
→ Tools → Workspace PATH, or `clide env path …` with capture-from-login-shell
suggestions. Worktrees share their repo's preset; stored machine-local, never
committed. (D-106, T-511)
### Changed
- Permission mode: Ctrl/Cmd+Shift+M cycles the full list including
bypassPermissions, and the composer menu's bypass row unlocks via
shift-click — plain Ctrl/Cmd+M and plain clicks stay safe-trio only (T-510).
- Bundled Claude skills pruned to clide-specific ones (T-507): broken/duplicate
skills removed, repo-agnostic ones moved to user scope, and stale guidance in
the remaining five (paths, commands, icon API) brought back in line with the
repo.
- The `clide` skill description now leads with the hosting mechanics (`clide`
on PATH, `CLIDE_SOCK`) and `clide capabilities` as the entry action, so an
agent deciding whether to invoke it sees the first move up front (T-512).
### Fixed
- Hash-keyed settings (per-workspace PATH presets, Claude account bindings)
with a number-shaped hash suffix were silently lost on restart — YAML map
keys are now quoted on write.
## [2.9.0] — 2026-06-30
### Added
+5
View File
@@ -0,0 +1,5 @@
{
"tab.title": { "translation": "Canvas" },
"empty": { "translation": "Open a .canvas file to view it here." },
"status.loading": { "translation": "Loading…" }
}
+3 -1
View File
@@ -106,7 +106,9 @@
"tool.name.TodoWrite": { "translation": "TodoWrite" },
"tool.name.ExitPlanMode": { "translation": "ExitPlanMode" },
"permissionControl.semantics": { "translation": "permission mode: {mode}. Activate to change." },
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles)" },
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)" },
"permissionControl.bypassHint": { "translation": "shift-click" },
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-click to enable" },
"permissionBadge.tooltip": { "translation": "Permission mode: {mode}. Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions." },
"permissionBadge.semantics": { "translation": "Permission mode: {label}" },
"card.expand": { "translation": "Expand" },
+9
View File
@@ -0,0 +1,9 @@
{
"tab.graph.title": { "translation": "Graph" },
"graph.empty": { "translation": "No linked notes in this vault." },
"graph.empty.filtered": { "translation": "No notes match the filter." },
"graph.empty.nolocal": { "translation": "Open a note to see its local graph." },
"graph.filter.glob": { "translation": "Path glob, e.g. notes/**" },
"graph.filter.depth": { "translation": "Local" },
"graph.filter.all": { "translation": "All" }
}
+16 -1
View File
@@ -7,5 +7,20 @@
"settings.field.d2.label": { "translation": "d2" },
"settings.field.d2.help": { "translation": "Absolute path to the d2 diagram compiler; blank to auto-resolve." },
"settings.field.detect.label": { "translation": "Re-detect" },
"settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." }
"settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." },
"settings.section.path": { "translation": "Workspace PATH" },
"settings.field.pathPreset.label": { "translation": "Prepend to PATH" },
"settings.field.pathPreset.help": { "translation": "Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo's preset. Applies to new shells." },
"path.noWorkspace": { "translation": "Open a workspace to set its PATH preset." },
"path.sharedRoot": { "translation": "Worktree — preset shared with" },
"path.empty": { "translation": "No preset entries — spawned shells get the resolved login-shell PATH as-is." },
"path.add": { "translation": "Add entry" },
"path.invalid": { "translation": "Enter an absolute path (or ~/…)." },
"path.missing": { "translation": "missing" },
"path.moveUp": { "translation": "Move up" },
"path.moveDown": { "translation": "Move down" },
"path.remove": { "translation": "Remove" },
"path.capture": { "translation": "Suggest from login shell" },
"path.captureNone": { "translation": "Nothing to suggest — the login-shell PATH is already covered." },
"path.addSuggestion": { "translation": "Add suggested entry" }
}
+5
View File
@@ -0,0 +1,5 @@
{
"tab.title": { "translation": "Canvas" },
"empty": { "translation": "Open een .canvas-bestand om het hier te bekijken." },
"status.loading": { "translation": "Laden…" }
}
+3 -1
View File
@@ -106,7 +106,9 @@
"tool.name.TodoWrite": { "translation": "Takenlijst" },
"tool.name.ExitPlanMode": { "translation": "Planmodus verlaten" },
"permissionControl.semantics": { "translation": "permissiemodus: {mode}. Activeer om te wijzigen." },
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt)" },
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt; +Shift ook bypass)" },
"permissionControl.bypassHint": { "translation": "shift-klik" },
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-klik om in te schakelen" },
"permissionBadge.tooltip": { "translation": "Permissiemodus: {mode}. Klik om te wisselen tussen default/acceptEdits/plan; Shift-klik voor bypassPermissions." },
"permissionBadge.semantics": { "translation": "Permissiemodus: {label}" },
"card.expand": { "translation": "Uitvouwen" },
+9
View File
@@ -0,0 +1,9 @@
{
"tab.graph.title": { "translation": "Grafiek" },
"graph.empty": { "translation": "Geen gekoppelde notities in deze vault." },
"graph.empty.filtered": { "translation": "Geen notities voldoen aan het filter." },
"graph.empty.nolocal": { "translation": "Open een notitie om de lokale grafiek te zien." },
"graph.filter.glob": { "translation": "Padpatroon, bijv. notes/**" },
"graph.filter.depth": { "translation": "Lokaal" },
"graph.filter.all": { "translation": "Alles" }
}
+16 -1
View File
@@ -7,5 +7,20 @@
"settings.field.d2.label": { "translation": "d2" },
"settings.field.d2.help": { "translation": "Absoluut pad naar de d2-diagramcompiler; leeg om automatisch te bepalen." },
"settings.field.detect.label": { "translation": "Opnieuw detecteren" },
"settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." }
"settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." },
"settings.section.path": { "translation": "Workspace-PATH" },
"settings.field.pathPreset.label": { "translation": "Vooraan aan PATH toevoegen" },
"settings.field.pathPreset.help": { "translation": "Mappen die vóór PATH komen in Claude-sessies en terminalvensters voor deze repo; worktrees delen de preset van de repo. Geldt voor nieuwe shells." },
"path.noWorkspace": { "translation": "Open een workspace om de PATH-preset in te stellen." },
"path.sharedRoot": { "translation": "Worktree — preset gedeeld met" },
"path.empty": { "translation": "Geen preset-items — nieuwe shells krijgen het opgeloste login-shell-PATH ongewijzigd." },
"path.add": { "translation": "Item toevoegen" },
"path.invalid": { "translation": "Voer een absoluut pad in (of ~/…)." },
"path.missing": { "translation": "ontbreekt" },
"path.moveUp": { "translation": "Omhoog" },
"path.moveDown": { "translation": "Omlaag" },
"path.remove": { "translation": "Verwijderen" },
"path.capture": { "translation": "Voorstellen uit login-shell" },
"path.captureNone": { "translation": "Niets voor te stellen — het login-shell-PATH is al gedekt." },
"path.addSuggestion": { "translation": "Voorgesteld item toevoegen" }
}
+1 -1
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.9.0"
version: "2.10.0"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
+25
View File
@@ -0,0 +1,25 @@
# clide feature requests
Pre-triage inbox for feature ideas surfaced from consumer use. Accepted
items move into the planning dataset (`pql ticket new …`); this file is
the scratchpad before that, not a parallel backlog. (Same convention as
pql's `feature-request.md`, pointed the other way.)
---
## FR-1 — Settings-panel option: per-repo preset PATH *(accepted → D-106 / T-511, 2026-07-13)*
Moved into the planning dataset: decision
[D-106](governance/decisions/tooling.md#d-106-per-workspace-path-preset-prepended-at-spawn)
and ticket T-511 (shipped). Kept here as a tombstone per this file's
convention; the problem, proposal, and design constraints are captured
in the D-record.
---
## FR-2 — Lead the `clide` skill `description` with `clide capabilities` *(accepted → T-512, 2026-07-13)*
Applied: the canonical `.claude/skills/clide/SKILL.md` `description` now
leads with the hosting context (`clide` on PATH, `CLIDE_SOCK`) and
`clide capabilities` as the entry action, keeping the trigger list.
Pure frontmatter wording; no behavior change.
+1
View File
@@ -147,6 +147,7 @@ You might also want, project-permitting:
- [D-103: Drawing-card primitive layer is SVG; the SVG renderer is the engine](decisions/architecture.md#d-103-drawing-card-primitive-layer-is-svg-the-svg-renderer-is-the-engine) — _architecture_
- [D-104: Explicit supporter-binary path overrides in user-scope settings](decisions/tooling.md#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings) — _tooling_
- [D-105: Support Vibe CLI as opt-in alternative to Claude Code CLI](decisions/llm.md#d-105-support-vibe-cli-as-opt-in-alternative-to-claude-code-cli) — _llm_
- [D-106: Per-workspace PATH preset prepended at spawn](decisions/tooling.md#d-106-per-workspace-path-preset-prepended-at-spawn) — _tooling_
## Open questions
+10 -2
View File
@@ -113,8 +113,16 @@ Toolchain, supply chain, CI, ignore strategy.
### D-104: Explicit supporter-binary path overrides in user-scope settings
- **Date:** 2026-06-28
- **Decision:** clide resolves each **external** supporter binary (`claude`, `d2`, and future supporter tools — **not** bundled `pql`/`git`, which keep [D-58]/[D-59]) through an **explicit `tools:` map in user-scope settings**: tool-name → absolute path. The map holds concrete, user-visible, user-editable paths and is the **first** step in resolution — if an entry is set, clide uses that exact path (honest error if it is missing / not executable, falling **down the chain with a warning** rather than hard-failing). On **first run**, clide auto-detects each tool **once** — probing the login-shell PATH (T-439) plus well-known dirs (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew`, Homebrew-on-Linux `/home/linuxbrew/.linuxbrew/bin`) — and **writes the discovered absolute paths into the map**, so detection is a one-time *populate*, not a per-launch heuristic; thereafter the pinned explicit value wins. A **re-detect** action re-runs the probe (e.g. after installing a tool). Unset/undetected tools fall back to the existing chain (bundled/pinned per [D-58]/[D-59], then login-shell PATH). **User-scope only, keyed by machine ([D-93]) — never committed** (absolute paths are machine-specific). Generalizes [D-58]'s `CLIDE_PQL_BIN` override to every supporter tool; surfaced in the Config tab and the Problems panel for unresolved tools.
- **Decision:** clide resolves each **external** supporter binary (`claude`, `d2`, and future supporter tools — **not** bundled `pql`/`git`, which keep [D-92]/[D-59]) through an **explicit `tools:` map in user-scope settings**: tool-name → absolute path. The map holds concrete, user-visible, user-editable paths and is the **first** step in resolution — if an entry is set, clide uses that exact path (honest error if it is missing / not executable, falling **down the chain with a warning** rather than hard-failing). On **first run**, clide auto-detects each tool **once** — probing the login-shell PATH (T-439) plus well-known dirs (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew`, Homebrew-on-Linux `/home/linuxbrew/.linuxbrew/bin`) — and **writes the discovered absolute paths into the map**, so detection is a one-time *populate*, not a per-launch heuristic; thereafter the pinned explicit value wins. A **re-detect** action re-runs the probe (e.g. after installing a tool). Unset/undetected tools fall back to the existing chain (bundled/pinned per [D-92]/[D-59], then login-shell PATH). **User-scope only, keyed by machine ([D-93]) — never committed** (absolute paths are machine-specific). Generalizes [D-92]'s `CLIDE_PQL_BIN` override to every supporter tool; surfaced in the Config tab and the Problems panel for unresolved tools.
- **Rationale:** PATH-probing (T-439) is adaptive but brittle on non-standard installs — a login-shell probe misses Homebrew when `brew shellenv` lives only in `~/.bashrc` (login shells source `.bash_profile`/`.profile`, not `.bashrc`). Materializing resolution into explicit, pinned paths makes it deterministic and debuggable: "tool not found" becomes a one-line settings fix, and the value is *visible* rather than recomputed by heuristic each launch. First-run auto-detect keeps it zero-config for standard installs; **pinning the result instead of re-probing is what makes it explicit** — escaping the heuristic fragility while keeping the convenience.
- **Cost:** A first-run detection pass plus a small settings surface. Stale pins (a tool moved on a brew upgrade) must fall back + warn, not hard-fail. The map is per-machine, so it does not travel with the repo — each machine detects once.
- **Cross-reference:** [D-58](#d-58-ship-pql-bundled-with-clide) (pql resolution + `CLIDE_PQL_BIN` override), [D-59](#d-59-bundled-git-via-dugite-native), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) (user-scope state), T-439 (login-shell-derived PATH), T-494 (the d2 template — first consumer).
- **Cross-reference:** [D-92](#d-92-ship-pql-bundled-with-clide) (pql resolution + `CLIDE_PQL_BIN` override), [D-59](#d-59-bundled-git-via-dugite-native), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) (user-scope state), T-439 (login-shell-derived PATH), T-494 (the d2 template — first consumer).
- **Raised by:** 2026-06-28 — user, during the drawing-card SVG/d2 work after the login-shell PATH probe was seen to miss linuxbrew: "should we just add the hard paths to the supporting binaries to the clide settings file?" + "auto detect on first run sounds solid" — explicit pinned paths, populated by a one-time first-run probe.
### D-106: Per-workspace PATH preset prepended at spawn
- **Date:** 2026-07-13
- **Decision:** Each workspace can carry a **preset list of directories prepended to the PATH** clide hands to everything it spawns a shell for: the **hosted Claude session** (so the agent's Bash tool sees it — the [D-83](architecture.md#d-83-dogfood-agent-model--hosted-stream-json-session-primary-external-cli-driver-secondary) bootstrap seam that already injects `CLIDE_SOCK`/`CLIDE_WORKSPACE`) and **terminal PTY panes**. Prepend semantics: preset dirs win over the resolved login-shell PATH (T-439), applied at spawn time — no profile sourcing, no login-shell requirement, and only NEW spawns see an edit. Storage is **user-scope, keyed by workspace hash ([D-93]) — never committed** (`app.env.pathPrepend.<hash>`; absolute paths are machine-specific, and a committed preset would let a cloned repo inject PATH). The hash keys off the **repo identity, not the literal directory**: a workspace that is a linked git worktree (its `.git` is a `gitdir:` pointer file, e.g. under an in-repo `.worktrees/` dir — supported, and gitignored by the scaffold) resolves to the **main repo root**, so every worktree shares its repo's preset. **Security fence:** the preset feeds only spawned-shell environments — clide's own binary resolution (pinned `tools:` per [D-104](#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings), bundled pql/git per [D-92](#d-92-ship-pql-bundled-with-clide)/[D-59](#d-59-bundled-git-via-dugite-native), the toolchain probe) never consults it (the T-98 dugite lesson). Parity ([D-6](architecture.md#d-6-cli-and-event-surface-contract)): `clide env path list|set|add|remove|clear|capture` mirrors the settings-panel editor; `capture` suggests entries by diffing the login-shell PATH against the process PATH — "set once by either party".
- **Rationale:** The T-439 login-shell probe is a best-effort global heuristic with a known residual hole — a login-but-non-interactive probe misses interactive-only profile additions (brew `shellenv` in `~/.bashrc`), which is exactly the observed failure (Go toolchain invisible to the agent in the pql repo, 2026-07-08). [D-104] made single binaries explicit; this extends the same explicit-over-heuristic philosophy to whole directories, per repo, visible and editable by both the user and the agent instead of re-discovered per session.
- **Cost:** Per-machine — a preset does not travel with the repo and re-keys (resets) when a repo moves ([D-93] trade-off, same as account bindings). One more env layer every spawn site must route through the single `shell_env` resolver; already-running shells keep their env until respawned.
- **Cross-reference:** [D-104](#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-83](architecture.md#d-83-dogfood-agent-model--hosted-stream-json-session-primary-external-cli-driver-secondary), [D-6](architecture.md#d-6-cli-and-event-surface-contract), [D-92](#d-92-ship-pql-bundled-with-clide), [D-59](#d-59-bundled-git-via-dugite-native), T-439 (login-shell PATH resolver), T-484 (spawn-time `CLAUDE_CONFIG_DIR` injection — the per-workspace spawn-env precedent).
- **Raised by:** 2026-07-08 — FR-1, logged from a pql session where the agent shell could not find the Go toolchain: "a settings-panel option (per repo/workspace): a list of PATH entries to prepend, set once, then injected everywhere clide spawns a shell"; worktree keying added 2026-07-13 — user: "make sure the PATH settings take usage of worktrees inside a .worktrees dir inside the repo into account".
+1
View File
@@ -1 +1,2 @@
export 'src/canvas_pane_host.dart';
export 'src/extension.dart';
+228
View File
@@ -0,0 +1,228 @@
/// Paints a [CanvasDoc] (T-322): group frames, edges, and node cards fitted
/// into the pane with a shared pan/zoom [CanvasViewport] — so hit-testing (a
/// later slice) lands exactly on what's drawn.
///
/// A `.canvas` is arbitrary user content, so node *colours* come from the file
/// (Obsidian presets `1`..`6` or `#rrggbb`), NOT clide's theme tokens; only the
/// chrome — the surface, default card, selection ring — is themed.
library;
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:flutter/widgets.dart';
/// The axis-aligned bounds of a doc's node rects, in canvas coordinates.
class CanvasBounds {
const CanvasBounds(this.left, this.top, this.right, this.bottom);
final double left, top, right, bottom;
double get width => right - left;
double get height => bottom - top;
factory CanvasBounds.of(CanvasDoc doc) {
if (doc.nodes.isEmpty) return const CanvasBounds(0, 0, 1, 1);
var l = double.infinity, t = double.infinity, r = double.negativeInfinity, b = double.negativeInfinity;
for (final n in doc.nodes) {
l = math.min(l, n.x);
t = math.min(t, n.y);
r = math.max(r, n.x + n.width);
b = math.max(b, n.y + n.height);
}
return CanvasBounds(l, t, r, b);
}
}
/// Fits a doc's [content] bounds into the pane (aspect-preserving, padded, and
/// centred), then applies the user's [zoom] (about the pane centre) and [pan].
class CanvasViewport {
CanvasViewport(this.scale, this.dx, this.dy);
final double scale, dx, dy;
factory CanvasViewport.fit(Size canvas, CanvasBounds content, {double zoom = 1, Offset pan = Offset.zero, double padding = 24}) {
final cw = content.width <= 0 ? 1.0 : content.width;
final ch = content.height <= 0 ? 1.0 : content.height;
final avw = math.max(1.0, canvas.width - 2 * padding);
final avh = math.max(1.0, canvas.height - 2 * padding);
final base = math.min(avw / cw, avh / ch);
final scale = (base.isFinite && base > 0 ? base : 1.0) * zoom;
final dx = canvas.width / 2 + pan.dx - scale * (content.left + cw / 2);
final dy = canvas.height / 2 + pan.dy - scale * (content.top + ch / 2);
return CanvasViewport(scale, dx, dy);
}
Offset toPixel(double x, double y) => Offset(dx + x * scale, dy + y * scale);
Rect rectOf(CanvasNode n) => Rect.fromLTWH(dx + n.x * scale, dy + n.y * scale, n.width * scale, n.height * scale);
}
/// The content colour of a node/edge: an Obsidian preset `"1".."6"`, a
/// `#rgb`/`#rrggbb` hex, or null when unset. Kept theme-independent on purpose.
Color? canvasContentColor(String? spec) {
if (spec == null || spec.isEmpty) return null;
if (spec.startsWith('#')) return _hex(spec);
return switch (spec) {
'1' => const Color(0xFFFB464C), // red
'2' => const Color(0xFFE9973F), // orange
'3' => const Color(0xFFE0DE71), // yellow
'4' => const Color(0xFF44CF6E), // green
'5' => const Color(0xFF53DFDD), // cyan
'6' => const Color(0xFFA882FF), // purple
_ => null,
};
}
/// The id of the topmost node under [local], or null. Uses the same
/// [CanvasViewport.fit] the painter draws with, so a click lands on what's
/// shown. Cards (drawn last) win over the group frames behind them.
String? hitTestCanvasNode(CanvasDoc doc, Offset local, Size size, {double zoom = 1, Offset pan = Offset.zero}) {
if (doc.isEmpty) return null;
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan);
for (final n in doc.nodes.reversed) {
if (n is! GroupNode && vp.rectOf(n).contains(local)) return n.id;
}
for (final n in doc.nodes.reversed) {
if (n is GroupNode && vp.rectOf(n).contains(local)) return n.id;
}
return null;
}
Color? _hex(String s) {
var h = s.substring(1);
if (h.length == 3) h = h.split('').map((c) => '$c$c').join();
if (h.length != 6) return null;
final v = int.tryParse(h, radix: 16);
return v == null ? null : Color(0xFF000000 | v);
}
class CanvasPainter extends CustomPainter {
CanvasPainter({required this.doc, required this.tokens, this.zoom = 1, this.pan = Offset.zero, this.selected});
final CanvasDoc doc;
final SurfaceTokens tokens;
final double zoom;
final Offset pan;
/// The id of the currently-selected node, drawn with a focus ring.
final String? selected;
@override
void paint(ui.Canvas canvas, Size size) {
if (doc.isEmpty) return;
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan);
final byId = {for (final n in doc.nodes) n.id: n};
// Groups sit behind everything as translucent framed regions.
for (final n in doc.nodes.whereType<GroupNode>()) {
_group(canvas, vp, n);
}
for (final e in doc.edges) {
_edge(canvas, vp, e, byId);
}
for (final n in doc.nodes) {
if (n is! GroupNode) _card(canvas, vp, n);
}
}
void _group(ui.Canvas canvas, CanvasViewport vp, GroupNode n) {
final rect = vp.rectOf(n);
final tint = canvasContentColor(n.color) ?? tokens.globalTextMuted;
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(6)), Paint()..color = tint.withValues(alpha: 0.06));
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(6)),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = tint.withValues(alpha: 0.5),
);
if (n.label != null && n.label!.isNotEmpty) {
_text(canvas, n.label!, Offset(rect.left + 6, rect.top + 4), tint, maxWidth: rect.width - 12, size: 11);
}
}
void _card(ui.Canvas canvas, CanvasViewport vp, CanvasNode n) {
final rect = vp.rectOf(n);
final content = canvasContentColor(n.color);
final border = content ?? tokens.globalBorder;
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(5)), Paint()..color = content?.withValues(alpha: 0.10) ?? tokens.panelBackground);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(5)),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = n.id == selected ? 2 : 1
..color = n.id == selected ? tokens.globalFocus : border,
);
final label = switch (n) {
TextNode(:final text) => text,
FileNode(:final file) => _basename(file),
LinkNode(:final url) => url,
_ => '',
};
if (label.isNotEmpty && rect.width > 16 && rect.height > 12) {
_text(canvas, label, Offset(rect.left + 6, rect.top + 5), tokens.globalForeground, maxWidth: rect.width - 12, maxLines: (rect.height ~/ 16).clamp(1, 6));
}
}
void _edge(ui.Canvas canvas, CanvasViewport vp, CanvasEdge e, Map<String, CanvasNode> byId) {
final from = byId[e.fromNode], to = byId[e.toNode];
if (from == null || to == null) return;
final a = _anchor(vp.rectOf(from), e.fromSide);
final b = _anchor(vp.rectOf(to), e.toSide);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..color = canvasContentColor(e.color) ?? tokens.dividerColor;
canvas.drawLine(a, b, paint);
if (e.toEnd == CanvasEnd.arrow) _arrowhead(canvas, b, a, paint.color);
if (e.fromEnd == CanvasEnd.arrow) _arrowhead(canvas, a, b, paint.color);
}
/// The attach point on a node rect: the midpoint of [side], or the centre.
Offset _anchor(Rect r, CanvasSide? side) => switch (side) {
CanvasSide.top => r.topCenter,
CanvasSide.bottom => r.bottomCenter,
CanvasSide.left => r.centerLeft,
CanvasSide.right => r.centerRight,
null => r.center,
};
void _arrowhead(ui.Canvas canvas, Offset tip, Offset from, Color color) {
final dir = (tip - from);
final len = dir.distance;
if (len < 0.01) return;
final u = dir / len;
const size = 7.0;
final base = tip - u * size;
final perp = Offset(-u.dy, u.dx) * (size * 0.5);
final path = Path()
..moveTo(tip.dx, tip.dy)
..lineTo(base.dx + perp.dx, base.dy + perp.dy)
..lineTo(base.dx - perp.dx, base.dy - perp.dy)
..close();
canvas.drawPath(path, Paint()..color = color);
}
void _text(ui.Canvas canvas, String text, Offset at, Color color, {required double maxWidth, int maxLines = 1, double size = 12}) {
if (maxWidth < 4) return;
final tp = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(fontSize: size, color: color),
),
textDirection: TextDirection.ltr,
maxLines: maxLines,
ellipsis: '',
)..layout(maxWidth: maxWidth);
tp.paint(canvas, at);
}
static String _basename(String path) {
final slash = path.lastIndexOf('/');
return slash >= 0 ? path.substring(slash + 1) : path;
}
@override
bool shouldRepaint(CanvasPainter old) => !identical(old.doc, doc) || old.tokens != tokens || old.zoom != zoom || old.pan != pan || old.selected != selected;
}
@@ -0,0 +1,111 @@
/// The canvas workspace pane body (T-322): renders the extension-owned
/// [MultitabController] of open `.canvas` documents as real sub-tabs.
/// The controller lives app-scoped on [CanvasExtension] (the diff/T-233
/// pattern) so open documents survive the pane being torn down while the
/// user works in another workspace tab.
library;
import 'dart:async';
import 'package:clide/builtin/canvas/src/canvas_view.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class CanvasPaneHost extends StatelessWidget {
const CanvasPaneHost({super.key, this.tabs});
/// Open documents, keyed by workspace path. Null until the extension
/// activates (the contribution builder captures the field lazily).
final MultitabController<String>? tabs;
@override
Widget build(BuildContext context) {
final controller = tabs;
if (controller == null) return const _EmptyHint();
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
if (controller.isEmpty) return const _EmptyHint();
return MultitabPane<String>(
controller: controller,
// Keep every document's State (parsed doc, pan/zoom, selection)
// alive across sub-tab switches.
keepAlive: true,
bodyBuilder: (_, entry) => CanvasDocumentTab(path: entry.payload),
);
},
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint();
@override
Widget build(BuildContext context) {
return Center(
child: ClideText(
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.canvas', placeholder: 'Open a .canvas file to view it here.'),
muted: true,
),
);
}
}
/// One open `.canvas` document: fetches [path] through `files.read`, parses
/// the JSONCanvas model, and hands it to the interactive [CanvasView].
class CanvasDocumentTab extends StatefulWidget {
const CanvasDocumentTab({super.key, required this.path});
final String path;
@override
State<CanvasDocumentTab> createState() => _CanvasDocumentTabState();
}
class _CanvasDocumentTabState extends State<CanvasDocumentTab> {
CanvasDoc? _doc;
String? _error;
bool _requested = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requested) return;
_requested = true;
unawaited(_load(ClideKernel.of(context)));
}
Future<void> _load(KernelServices kernel) async {
final resp = await kernel.ipc.request('files.read', args: {'path': widget.path});
if (!mounted) return;
if (!resp.ok) {
setState(() => _error = resp.error?.message ?? widget.path);
return;
}
try {
final doc = CanvasDoc.parse(resp.data['content'] as String? ?? '');
setState(() => _doc = doc);
} on FormatException catch (e) {
setState(() => _error = e.message);
}
}
@override
Widget build(BuildContext context) {
final error = _error;
if (error != null) {
return Padding(padding: const EdgeInsets.all(12), child: ClideText(error, muted: true));
}
final doc = _doc;
if (doc == null) {
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.canvas', placeholder: 'Loading…'), muted: true),
);
}
return CanvasView(doc: doc);
}
}
+74
View File
@@ -0,0 +1,74 @@
/// The interactive `.canvas` view (T-322): lays a [CanvasDoc] out with
/// [CanvasPainter] and wires pan (drag), zoom (scroll wheel), and click-select
/// via [hitTestCanvasNode] so the selection ring lands on what's drawn. Node
/// drag/resize and edit affordances layer on later.
library;
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:clide/widgets/src/clide_settings.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
class CanvasView extends StatefulWidget {
const CanvasView({super.key, required this.doc, this.onSelect});
final CanvasDoc doc;
/// Fired with the selected node's id (or null when the click misses).
final void Function(String? nodeId)? onSelect;
@override
State<CanvasView> createState() => _CanvasViewState();
}
class _CanvasViewState extends State<CanvasView> {
static const double _minZoom = 0.2, _maxZoom = 5;
double _zoom = 1;
Offset _pan = Offset.zero;
String? _selected;
@override
void didUpdateWidget(CanvasView old) {
super.didUpdateWidget(old);
if (!identical(old.doc, widget.doc)) {
_zoom = 1;
_pan = Offset.zero;
_selected = null;
}
}
void _onScroll(PointerScrollEvent e) {
final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9;
final next = (_zoom * factor).clamp(_minZoom, _maxZoom);
if (next != _zoom) setState(() => _zoom = next);
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return LayoutBuilder(
builder: (ctx, constraints) {
final size = constraints.biggest;
return Listener(
onPointerSignal: (s) {
if (s is PointerScrollEvent) _onScroll(s);
},
child: GestureDetector(
onTapUp: (d) {
final hit = hitTestCanvasNode(widget.doc, d.localPosition, size, zoom: _zoom, pan: _pan);
if (hit != _selected) setState(() => _selected = hit);
widget.onSelect?.call(hit);
},
onPanUpdate: (d) => setState(() => _pan += d.delta),
child: CustomPaint(
size: size,
painter: CanvasPainter(doc: widget.doc, tokens: tokens, zoom: _zoom, pan: _pan, selected: _selected),
),
),
);
},
);
}
}
+87 -6
View File
@@ -1,17 +1,98 @@
import 'package:clide/extension/extension.dart';
import 'dart:async';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
import 'package:clide/builtin/canvas/src/canvas_pane_host.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
/// Tier-5 interactive `.canvas` pane (T-322). Renders Obsidian JSONCanvas
/// documents in a workspace tab; each open document is a real sub-tab
/// (MultitabPane). Opens route in as `selection` messages — from
/// `openWorkspaceFile` (file tree, quick-open) and from
/// `clide ui open canvas [path]` (D-6 parity, the diff/T-233 pattern).
class CanvasExtension extends ClideExtension {
@override
String get id => 'builtin.canvas';
@override
String get title => 'Canvas';
@override
String get version => '0.0.0-stub';
String get version => '0.1.0';
@override
List<String> get dependsOn => const [];
/// App-scoped so open documents survive the pane view being (re)built
/// while another workspace tab is active. Built in [activate]; the
/// contribution's build closure reads the field at widget-build time.
MultitabController<String>? _tabs;
StreamSubscription<Message>? _selectionSub;
StreamSubscription<ProjectOpened>? _projectSub;
String? _projectRoot;
@override
List<ContributionPoint> get contributions => const [];
List<ContributionPoint> get contributions => [
TabContribution(
id: 'canvas.view',
slot: Slots.workspace,
title: 'Canvas',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -60, // below the readers' home surfaces, near diff (-70)
build: (_) => CanvasPaneHost(tabs: _tabs),
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
_tabs = MultitabController<String>();
_selectionSub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
final path = msg.data['path'];
if (path is! String || path.isEmpty) return;
openPath(path);
ctx.panels.activateTab(Slots.workspace, 'canvas.view');
});
_projectSub = ctx.events.on<ProjectOpened>().listen(_onProjectChanged);
}
/// Focus the sub-tab for [path], opening one when the document isn't
/// open yet. Tab id is the path itself — one tab per document.
void openPath(String path) {
final tabs = _tabs;
if (tabs == null) return;
if (tabs.entries.any((e) => e.id == path)) {
tabs.activate(path);
} else {
tabs.add(MultitabEntry<String>(id: path, title: _basename(path), payload: path));
}
}
/// Paths of the open documents, oldest-first.
List<String> get openPaths => _tabs?.entries.map((e) => e.id).toList() ?? const [];
/// Drop every open document when the workspace switches in place
/// (T-269): the old repo's paths don't resolve in the new one.
void _onProjectChanged(ProjectOpened e) {
final prev = _projectRoot;
_projectRoot = e.path;
if (prev == null || prev == e.path) return;
final tabs = _tabs;
if (tabs == null) return;
for (final id in tabs.entries.map((x) => x.id).toList()) {
tabs.remove(id);
}
}
static String _basename(String path) {
final i = path.lastIndexOf('/');
return i < 0 ? path : path.substring(i + 1);
}
@override
Future<void> deactivate() async {
await _selectionSub?.cancel();
_selectionSub = null;
await _projectSub?.cancel();
_projectSub = null;
_tabs?.dispose();
_tabs = null;
}
}
+32 -8
View File
@@ -23,6 +23,7 @@ library;
import 'dart:io';
import 'package:clide/src/env/path_preset.dart' show applyPathPreset;
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
@@ -74,12 +75,22 @@ String clideSkillsNote() =>
///
/// * `CLIDE_SOCK` — the per-workspace socket ([workspaceSocketPath], D-70).
/// * `CLIDE_WORKSPACE` — the workspace root.
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
/// is not already resolvable), otherwise left untouched.
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
/// * `PATH` — the workspace's preset dirs (D-106), then [clideCliDir] when it
/// is non-null (i.e. `clide` is not already resolvable), then
/// [currentPath]. Exported whenever there is anything to prepend —
/// the preset must reach the session's Bash tool even when `clide` is
/// already on PATH — and left untouched otherwise.
Map<String, String> agentEnvDelta({
required String workspaceRoot,
required String socketPath,
required String? currentPath,
required String? clideCliDir,
List<String> prependDirs = const [],
}) {
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
if (clideCliDir != null && clideCliDir.isNotEmpty) {
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
final prepend = <String>[...prependDirs, if (clideCliDir != null && clideCliDir.isNotEmpty) clideCliDir];
if (prepend.isNotEmpty) {
delta['PATH'] = applyPathPreset(currentPath ?? '', prepend);
}
return delta;
}
@@ -132,11 +143,18 @@ class AgentBootstrap {
/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs]
/// carries the context note; team callers append their own preamble and the
/// orchestrator merges both into one `--append-system-prompt`.
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base, String? Function(String cwd)? boundConfigDir}) {
AgentBootstrap agentBootstrap(
String workspaceRoot, {
Map<String, String>? base,
String? Function(String cwd)? boundConfigDir,
List<String> Function(String cwd)? pathPreset,
}) {
final home = Platform.environment['HOME'];
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
// shells out to — find user-installed components on a desktop launch, not just
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir and the
// workspace's PATH preset (D-106), injected as a plain lookup like
// [boundConfigDir] so this stays Flutter-free.
final currentPath = resolvedToolPath();
final candidates = <String>[
if (home != null && home.isNotEmpty) '$home/.local/bin',
@@ -144,7 +162,13 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base,
File(Platform.resolvedExecutable).parent.path,
];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
final delta = agentEnvDelta(
workspaceRoot: workspaceRoot,
socketPath: workspaceSocketPath(workspaceRoot),
currentPath: currentPath,
clideCliDir: cliDir,
prependDirs: pathPreset?.call(workspaceRoot) ?? const [],
);
// Per-repo Claude account (T-484): a bound workspace runs claude under that
// account's CLAUDE_CONFIG_DIR. Spread BEFORE base so an explicit per-call
// SpawnSpec.env override still wins (precedence: override > binding > parent
+25 -9
View File
@@ -50,6 +50,7 @@ class ClaudeComposer extends StatefulWidget {
this.onInterrupt,
this.busy = false,
this.onCycleMode,
this.onCycleModeFull,
this.permissionMode,
this.onSetPermissionMode,
this.initialValue,
@@ -83,11 +84,17 @@ class ClaudeComposer extends StatefulWidget {
/// (when the typeahead is closed). The escape hatch for a runaway turn.
final VoidCallback? onInterrupt;
/// Cycle the session's permission mode — fired by Ctrl/Cmd+M while the
/// composer is focused (T-226). Intercepted here (not a global keymap
/// binding) so it targets this pane's session. Null disables the chord.
/// Cycle the session's permission mode through the safe trio — fired by
/// Ctrl/Cmd+M while the composer is focused (T-226). Intercepted here
/// (not a global keymap binding) so it targets this pane's session.
/// Null disables the chord.
final VoidCallback? onCycleMode;
/// Cycle through the FULL mode list including bypassPermissions — fired
/// by Ctrl/Cmd+Shift+M (T-510). Holding shift is the explicit opt-in
/// for the footgun. Null disables the chord.
final VoidCallback? onCycleModeFull;
/// Whether a turn is in flight; shows the Stop affordance.
final bool busy;
@@ -255,13 +262,22 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
// Ctrl/Cmd+M: cycle the session's permission mode (T-226). Intercepted
// here so it targets this pane. (Shift+Tab — the CLI chord — is off the
// table: it's a real a11y focus-traversal binding.)
// Ctrl/Cmd+M: cycle the session's permission mode through the safe trio
// (T-226); with Shift held, through the full list including bypass
// (T-510). Intercepted here so it targets this pane. (Shift+Tab — the
// CLI chord — is off the table: it's a real a11y focus-traversal
// binding.)
final mod = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
if (mod && e.logicalKey == LogicalKeyboardKey.keyM && widget.onCycleMode != null) {
widget.onCycleMode!();
return KeyEventResult.handled;
if (mod && e.logicalKey == LogicalKeyboardKey.keyM) {
final shift = HardwareKeyboard.instance.isShiftPressed;
if (shift && widget.onCycleModeFull != null) {
widget.onCycleModeFull!();
return KeyEventResult.handled;
}
if (!shift && widget.onCycleMode != null) {
widget.onCycleMode!();
return KeyEventResult.handled;
}
}
// Escape: dismiss the typeahead if open, otherwise interrupt the running
// turn — the escape hatch from a runaway (D-78).
+12 -2
View File
@@ -613,14 +613,23 @@ class _ClaudePaneState extends State<ClaudePane> {
/// Cycle this pane's session through the safe permission-mode trio
/// (default → acceptEdits → plan → default), sent over the stream-json
/// control channel (T-226). bypassPermissions is not reachable here — it
/// stays behind the explicit confirmed path in the cockpit roster (T-181).
/// control channel (T-226). bypassPermissions is not in the plain chord —
/// it's in the shift-modified full cycle ([_cycleModeFull], T-510).
void _cycleMode() {
final s = _session;
if (s == null) return;
s.setPermissionMode(nextSafePermissionMode(_status.permissionMode ?? 'default'));
}
/// Cycle through the full mode list including bypassPermissions —
/// Ctrl/Cmd+Shift+M, where holding shift is the explicit opt-in for the
/// footgun (T-510).
void _cycleModeFull() {
final s = _session;
if (s == null) return;
s.setPermissionMode(nextPermissionMode(_status.permissionMode ?? 'default'));
}
/// Focus the composer when the user taps empty conversation area (T-227).
/// No-op while a prompt occupies the interaction zone (D-78) — a
/// background tap must never pull focus from (or resurrect) the composer
@@ -835,6 +844,7 @@ class _ClaudePaneState extends State<ClaudePane> {
busy: busySnap.data ?? false,
onInterrupt: _session?.interrupt,
onCycleMode: _cycleMode,
onCycleModeFull: _cycleModeFull,
permissionMode: _status.permissionMode,
onSetPermissionMode: _session != null ? (m) => _session!.setPermissionMode(m) : null,
onSubmit: _send,
+15 -2
View File
@@ -41,10 +41,16 @@ String shortModelLabel(String model) {
}
/// The safe permission-mode cycle: default → acceptEdits → plan → default
/// (T-226/T-181). `bypassPermissions` is intentionally excluded — it's
/// reachable only via an explicit confirmed path (the footgun guard).
/// (T-226/T-181). `bypassPermissions` is intentionally excluded from the
/// plain chord — it lives in [kFullPermissionCycle] behind the shift
/// modifier (T-510).
const List<String> kSafePermissionCycle = ['default', 'acceptEdits', 'plan'];
/// The full cycle including the bypass footgun. Reachable only through
/// shift-modified gestures (Ctrl/Cmd+Shift+M, shift-click on the popup's
/// bypass row) — holding shift is the explicit opt-in (T-510).
const List<String> kFullPermissionCycle = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
/// The next mode in [kSafePermissionCycle] after [current] (wraps). An
/// unknown or `bypassPermissions` current restarts the cycle at `default`.
String nextSafePermissionMode(String current) {
@@ -52,6 +58,13 @@ String nextSafePermissionMode(String current) {
return kSafePermissionCycle[(i + 1) % kSafePermissionCycle.length];
}
/// The next mode in [kFullPermissionCycle] after [current] (wraps). An
/// unknown current restarts the cycle at `default`.
String nextPermissionMode(String current) {
final i = kFullPermissionCycle.indexOf(current);
return kFullPermissionCycle[(i + 1) % kFullPermissionCycle.length];
}
/// Status-line segments split around the permission-mode badge so the UI can
/// render the mode as an interactive control between them (T-226). `leading`
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
+8 -2
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/account_registry.dart';
import 'package:clide/src/env/path_preset.dart' show presetDirsFrom;
import 'package:clide/builtin/claude/src/account_login_dialog.dart';
import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart';
import 'package:clide/builtin/claude/src/account_settings_control.dart';
@@ -511,8 +512,13 @@ class ClaudeExtension extends ClideExtension {
// The clide-managed session set (T-169). Panes spawn/bind through it so a
// session outlives its pane and is shared across surfaces. The account
// registry (T-476) lets a bound workspace spawn under its own Claude account.
_orchestrator = ClaudeSessionOrchestrator(accountRegistry: AccountRegistry(ctx.settings));
// registry (T-476) lets a bound workspace spawn under its own Claude
// account; the PATH-preset lookup (D-106) prepends the workspace's preset
// dirs to every hosted session's PATH — read live at each spawn.
_orchestrator = ClaudeSessionOrchestrator(
accountRegistry: AccountRegistry(ctx.settings),
pathPresetFor: (cwd) => presetDirsFrom((k) => ctx.settings.get<Object>(k), cwd),
);
activeSessionOrchestrator = _orchestrator;
// An in-place workspace switch (Open Project/Folder) must not leave the
@@ -3,15 +3,18 @@
///
/// Shows the current mode as a per-mode coloured glyph; clicking opens a menu
/// of the safe trio (default / accept-edits / plan) with the active one marked,
/// plus a divided, disabled `bypass` row — that footgun stays behind the
/// cockpit's explicit confirmed path (T-181), never one click away here. The
/// label lives in the tooltip, the menu rows, and the status-bar indicator —
/// the resting button is the glyph alone.
/// plus a divided `bypass` row gated behind shift-click (T-510): a plain click
/// no-ops and keeps the menu open, shift-click selects — the footgun is never
/// a *plain* click away, and holding shift is the explicit opt-in (same
/// convention as the roster badge, T-181). The label lives in the tooltip, the
/// menu rows, and the status-bar indicator — the resting button is the glyph
/// alone.
library;
import 'package:clide/builtin/claude/src/claude_status.dart' show kSafePermissionCycle, permissionModeLabel;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show HardwareKeyboard;
import 'package:flutter/widgets.dart';
/// Per-mode glyph. `bypass` reuses a warning shield; the safe trio gets a
@@ -66,7 +69,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
super.dispose();
}
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
List<ClideMenuEntry> _entries(BuildContext ctx, SurfaceTokens tokens) => [
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
@@ -76,13 +79,33 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
// The footgun row (T-510): a plain click/Enter no-ops and keeps the
// menu open; with shift held it selects and closes. The trailing hint
// names the gesture (suppressed while active — the check mark wins).
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
keepOpenOnSelect: true,
semanticLabel: ClideSettings.i18n.string(
ctx,
'permissionControl.bypassSemantics',
namespace: 'builtin.claude',
placeholder: 'bypassPermissions — shift-click to enable',
),
trailing: widget.mode == 'bypassPermissions'
? null
: ClideText(
ClideSettings.i18n.string(ctx, 'permissionControl.bypassHint', namespace: 'builtin.claude', placeholder: 'shift-click'),
fontSize: 10,
color: tokens.globalTextMuted,
),
onSelect: () {
if (!HardwareKeyboard.instance.isShiftPressed) return;
widget.onSelect('bypassPermissions');
_overlay.close();
},
),
];
@@ -94,7 +117,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -6),
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ctx, ClideSettings.theme.of(ctx).surface)),
anchor: ListenableBuilder(
listenable: _overlay,
builder: (ctx, _) {
@@ -115,7 +138,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
context,
'permissionControl.tooltip',
namespace: 'builtin.claude',
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles)',
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)',
replacers: [I18nReplacer(from: '{mode}', replace: permissionModeLabel(widget.mode))],
),
builder: (ctx, hovered, _) => Container(
@@ -153,7 +153,7 @@ class ManagedSession {
ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry}) : _factory = processFactory ?? _spawnClaude {
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry, this.pathPresetFor}) : _factory = processFactory ?? _spawnClaude {
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
}
@@ -162,6 +162,11 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// Null in tests / when no registry is wired → no injection.
final AccountRegistry? accountRegistry;
/// Per-workspace PATH preset lookup (D-106): dirs prepended to a hosted
/// session's PATH at spawn, wired by the extension over the settings store.
/// Null in tests / when not wired → no injection.
final List<String> Function(String cwd)? pathPresetFor;
final ProcessFactory _factory;
final _sessions = <String, ManagedSession>{};
@@ -255,7 +260,12 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
preambles.add(_teamSystemPrompt(name, spec.role));
}
final bootstrap = agentBootstrap(spec.cwd, base: spec.env, boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir);
final bootstrap = agentBootstrap(
spec.cwd,
base: spec.env,
boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir,
pathPreset: pathPresetFor,
);
sessionArgs = [
'--append-system-prompt',
preambles.join('\n\n'),
+15 -1
View File
@@ -1,4 +1,7 @@
import 'package:clide/builtin/graph/src/graph_panel.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
class GraphExtension extends ClideExtension {
@override
@@ -11,5 +14,16 @@ class GraphExtension extends ClideExtension {
List<String> get dependsOn => const ['builtin.pql'];
@override
List<ContributionPoint> get contributions => const [];
List<ContributionPoint> get contributions => [
TabContribution(
id: 'graph.view',
slot: Slots.contextPanel,
title: 'Graph',
titleKey: 'tab.graph.title',
i18nNamespace: id,
icon: PhosphorIcons.byName('graph'),
priority: -70,
build: (_) => const GraphPanel(),
),
];
}
+160
View File
@@ -0,0 +1,160 @@
/// Loads the whole vault's link graph from pql (T-323): lists every markdown
/// file (nodes), then fetches each file's `pql.meta` for its outlinks (edges)
/// and tags, assembling a [VaultGraph] plus a per-file tag map. Holds the file
/// glob + a client-side [GraphFilter] (tag include/exclude, depth-from-active)
/// and exposes the filtered [visibleGraph] the pane draws.
///
/// One `pql.meta` per file gives both outlinks and tags in a single call. Link
/// targets carry `#heading` fragments (`foo.md#bar`); those are stripped to the
/// file (`foo.md`) so a heading link still connects the two notes.
library;
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/graph/graph_filter.dart';
import 'package:clide/src/graph/vault_graph.dart';
import 'package:flutter/foundation.dart';
class GraphController extends ChangeNotifier {
GraphController({required this.ipc, required this.events, String glob = '**/*.md', this.refreshDebounce = const Duration(milliseconds: 400)}) : _glob = glob {
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
}
final DaemonClient ipc;
final DaemonBus events;
/// A save touches several `files.changed` events in a burst — coalesce them
/// into one full reload rather than rebuilding the graph per file.
final Duration refreshDebounce;
String _glob;
String get glob => _glob;
StreamSubscription<DaemonEvent>? _eventSub;
Timer? _debounce;
VaultGraph _graph = const VaultGraph([], []);
VaultGraph get graph => _graph;
Map<String, Set<String>> _tagsByPath = const {};
/// Every tag present in the loaded vault, sorted — what the filter UI offers.
List<String> get availableTags {
final all = <String>{for (final s in _tagsByPath.values) ...s};
return all.toList()..sort();
}
String? _activePath;
String? get activePath => _activePath;
GraphFilter _filter = const GraphFilter();
GraphFilter get filter => _filter;
bool _loading = false;
bool get loading => _loading;
String? _error;
String? get error => _error;
/// The graph after the active [filter] — what the pane draws.
VaultGraph get visibleGraph => _filter.apply(_graph, tagsByPath: _tagsByPath, activePath: _activePath);
/// List every in-scope file, fetch each one's meta (outlinks + tags), and
/// rebuild the graph. A failed `pql.files` clears everything and surfaces the
/// error; a failed per-file `pql.meta` just contributes no edges/tags for it.
Future<void> load() async {
_loading = true;
_error = null;
notifyListeners();
final filesResp = await ipc.request('pql.files', args: {'glob': _glob});
if (!filesResp.ok) {
_graph = const VaultGraph([], []);
_tagsByPath = const {};
_error = filesResp.error?.message ?? 'pql.files failed';
_loading = false;
notifyListeners();
return;
}
final paths = [
for (final f in _castList(filesResp.data['files']))
if (f['path'] is String) f['path'] as String,
];
final outlinks = <String, List<String>>{};
final tags = <String, Set<String>>{};
for (final path in paths) {
final resp = await ipc.request('pql.meta', args: {'path': path});
if (!resp.ok) {
outlinks[path] = const [];
continue;
}
outlinks[path] = [
for (final l in _castList(resp.data['outlinks']))
if (l['target'] is String) _stripFragment(l['target'] as String),
].where((t) => t.isNotEmpty).toList();
final t = resp.data['tags'];
if (t is List) {
final set = {
for (final e in t)
if (e is String) e,
};
if (set.isNotEmpty) tags[path] = set;
}
}
_graph = VaultGraph.fromOutlinks(outlinks);
_tagsByPath = tags;
_loading = false;
notifyListeners();
}
/// Change the file set the graph spans. An empty glob resets to all markdown.
/// A real change re-queries pql; the same glob is a no-op.
void setGlob(String glob) {
final g = glob.trim().isEmpty ? '**/*.md' : glob.trim();
if (g == _glob) return;
_glob = g;
unawaited(load());
}
/// Replace the client-side filter. No reload — [visibleGraph] recomputes.
void setFilter(GraphFilter filter) {
_filter = filter;
notifyListeners();
}
void _onEvent(DaemonEvent e) {
if (e.subsystem == 'editor' && e.kind == 'editor.active-changed') {
final p = e.data['path'] as String?;
if (p != _activePath) {
_activePath = p;
notifyListeners(); // a depth filter re-centres on the new active note
}
return;
}
if (e.subsystem != 'files') return;
_debounce?.cancel();
_debounce = Timer(refreshDebounce, () => unawaited(load()));
}
static String _stripFragment(String target) {
final hash = target.indexOf('#');
return hash < 0 ? target : target.substring(0, hash);
}
static List<Map<String, Object?>> _castList(Object? raw) {
if (raw is! List) return const [];
return [for (final e in raw) (e as Map).cast<String, Object?>()];
}
@override
void dispose() {
_debounce?.cancel();
_eventSub?.cancel();
_eventSub = null;
super.dispose();
}
}
+145
View File
@@ -0,0 +1,145 @@
/// Paints a laid-out [VaultGraph] (T-323): edges as lines, nodes as labelled
/// dots, with a hover neighbourhood lit and everything else dimmed.
///
/// The [ForceLayout] solver produces [positions] in a [layoutSize] space; the
/// painter fits that into the canvas (aspect-preserving, centered). The graph is
/// clide's own UI here (not arbitrary content), so it paints through
/// [SurfaceTokens] — theme chrome, per D-7.
library;
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/src/graph/force_layout.dart';
import 'package:clide/src/graph/vault_graph.dart';
import 'package:flutter/widgets.dart';
/// The aspect-preserving, centered fit of the solver's [layoutSize] space into a
/// canvas — shared by the painter and hit-testing so hover/click land exactly on
/// what's drawn.
class GraphViewport {
GraphViewport(this.scale, this.dx, this.dy);
final double scale, dx, dy;
/// Fits the solver's [layout] space into [canvas] (aspect-preserving), then
/// applies the user's [zoom] (about the canvas centre) and [pan]. At
/// `zoom: 1, pan: zero` this is the plain centered fit.
factory GraphViewport.fit(Size canvas, Size layout, {double zoom = 1, Offset pan = Offset.zero}) {
final scale = math.min(canvas.width / layout.width, canvas.height / layout.height) * zoom;
// Pin the layout centre to the canvas centre so zoom scales about it, then
// translate by the pan.
final dx = canvas.width / 2 + pan.dx - scale * layout.width / 2;
final dy = canvas.height / 2 + pan.dy - scale * layout.height / 2;
return GraphViewport(scale, dx, dy);
}
Offset toPixel(GraphPoint p) => Offset(dx + p.x * scale, dy + p.y * scale);
}
/// The node id nearest [local] within [hitRadius] px, or null — the inverse of
/// [GraphViewport], so it matches what [GraphPainter] drew.
String? hitTestNode(
VaultGraph graph,
Map<String, GraphPoint> positions,
Offset local,
Size size, {
Size layoutSize = const Size(800, 600),
double hitRadius = 12,
double zoom = 1,
Offset pan = Offset.zero,
}) {
if (positions.isEmpty) return null;
final vp = GraphViewport.fit(size, layoutSize, zoom: zoom, pan: pan);
String? best;
var bestD = hitRadius;
for (final n in graph.nodes) {
final p = positions[n.id];
if (p == null) continue;
final d = (vp.toPixel(p) - local).distance;
if (d <= bestD) {
bestD = d;
best = n.id;
}
}
return best;
}
class GraphPainter extends CustomPainter {
GraphPainter({
required this.graph,
required this.positions,
required this.tokens,
this.highlight,
this.layoutSize = const Size(800, 600),
this.zoom = 1,
this.pan = Offset.zero,
});
final VaultGraph graph;
final Map<String, GraphPoint> positions;
final SurfaceTokens tokens;
/// When non-null, only these node ids (a hovered node's neighbourhood) render
/// at full strength; the rest dim.
final Set<String>? highlight;
final Size layoutSize;
/// User pan/zoom over the base fit — kept in lockstep with [hitTestNode].
final double zoom;
final Offset pan;
static const double nodeRadius = 5;
@override
void paint(ui.Canvas canvas, Size size) {
if (graph.isEmpty || positions.isEmpty) return;
final vp = GraphViewport.fit(size, layoutSize, zoom: zoom, pan: pan);
Offset at(GraphPoint p) => vp.toPixel(p);
bool lit(String id) => highlight == null || highlight!.contains(id);
final edge = Paint()
..strokeWidth = 1
..color = tokens.dividerColor;
final edgeDim = Paint()
..strokeWidth = 1
..color = tokens.dividerColor.withValues(alpha: 0.2);
for (final e in graph.edges) {
final a = positions[e.from], b = positions[e.to];
if (a == null || b == null) continue;
canvas.drawLine(at(a), at(b), lit(e.from) && lit(e.to) ? edge : edgeDim);
}
for (final n in graph.nodes) {
final p = positions[n.id];
if (p == null) continue;
final o = at(p);
final on = lit(n.id);
canvas.drawCircle(o, nodeRadius, Paint()..color = on ? tokens.globalForeground : tokens.globalTextMuted.withValues(alpha: 0.35));
if (on) _label(canvas, n.label, o);
}
}
void _label(ui.Canvas canvas, String text, Offset o) {
final tp = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(fontSize: 10, color: tokens.globalForeground),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout(maxWidth: 120);
tp.paint(canvas, Offset(o.dx - tp.width / 2, o.dy + nodeRadius + 2));
}
@override
bool shouldRepaint(GraphPainter old) =>
!identical(old.graph, graph) ||
!identical(old.positions, positions) ||
old.highlight != highlight ||
old.tokens != tokens ||
old.zoom != zoom ||
old.pan != pan;
}
+197
View File
@@ -0,0 +1,197 @@
/// The vault link-graph context panel (T-323): loads the whole vault's link
/// graph from pql via [GraphController] and renders the filtered [GraphView],
/// wiring a node click to open that note in the editor.
///
/// Above the graph sits a filter bar — a path glob (re-queries pql on submit),
/// a depth-from-active selector (the local graph around the open note), and
/// tri-state tag pills (neutral → include → exclude). Loading / error / empty
/// states show until there's a graph; a debounced refresh keeps the existing
/// graph on screen rather than flashing back to the spinner.
library;
import 'dart:async';
import 'package:clide/builtin/graph/src/graph_controller.dart';
import 'package:clide/builtin/graph/src/graph_view.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/graph/graph_filter.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class GraphPanel extends StatefulWidget {
const GraphPanel({super.key});
@override
State<GraphPanel> createState() => _GraphPanelState();
}
class _GraphPanelState extends State<GraphPanel> {
GraphController? _controller;
DaemonClient? _ipc;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_ipc = kernel.ipc;
_controller = GraphController(ipc: kernel.ipc, events: kernel.events);
unawaited(_controller!.load());
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
void _open(String nodeId) => unawaited(_ipc?.request('editor.open', args: {'path': nodeId}));
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.graph', placeholder: fallback);
@override
Widget build(BuildContext context) {
final c = _controller;
if (c == null) return const SizedBox.shrink();
return ListenableBuilder(
listenable: c,
builder: (context, _) {
// Before we have any graph, the pre-load states own the whole panel.
if (c.graph.isEmpty) {
if (c.loading) return const Center(child: ClideSpinner(size: 20, semanticLabel: 'Loading graph'));
if (c.error != null) return _message(ClideSettings.theme.of(context).surface.statusError, c.error!);
return _message(null, _t('graph.empty', 'No linked notes in this vault.'));
}
final visible = c.visibleGraph;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_GraphFilterBar(controller: c, t: _t),
Expanded(
child: visible.isEmpty
? _message(
null,
c.filter.depth != null && c.activePath == null
? _t('graph.empty.nolocal', 'Open a note to see its local graph.')
: _t('graph.empty.filtered', 'No notes match the filter.'),
)
: GraphView(graph: visible, onOpen: _open),
),
],
);
},
);
}
Widget _message(Color? color, String text) => Padding(
padding: const EdgeInsets.all(16),
child: ClideText(text, color: color, muted: color == null, fontSize: clideFontCaption, textAlign: TextAlign.center),
);
}
/// The glob + depth + tag controls above the graph. Reads/writes the
/// controller's glob and [GraphFilter]; keeps no state of its own.
class _GraphFilterBar extends StatelessWidget {
const _GraphFilterBar({required this.controller, required this.t});
final GraphController controller;
final String Function(String key, String fallback) t;
static const _depths = <(String, int?)>[('All', null), ('1', 1), ('2', 2), ('3', 3)];
void _setDepth(int? depth) => controller.setFilter(controller.filter.copyWith(depth: depth, clearDepth: depth == null));
void _cycleTag(String tag) {
final f = controller.filter;
final inc = {...f.includeTags}, exc = {...f.excludeTags};
if (inc.contains(tag)) {
inc.remove(tag);
exc.add(tag); // include → exclude
} else if (exc.contains(tag)) {
exc.remove(tag); // exclude → neutral
} else {
inc.add(tag); // neutral → include
}
controller.setFilter(f.copyWith(includeTags: inc, excludeTags: exc));
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final f = controller.filter;
final tags = controller.availableTags;
return Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
),
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ClideFilterBox(
hint: t('graph.filter.glob', 'Path glob, e.g. notes/**'),
showIcon: true,
icon: PhosphorIcons.byName('funnel'),
onChanged: (_) {}, // reload is heavy (1+N pql calls) — apply on submit only
onSubmitted: controller.setGlob,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
ClideText(t('graph.filter.depth', 'Local'), fontSize: clideFontCaption, color: tokens.globalTextMuted),
const SizedBox(width: 8),
for (final (label, depth) in _depths)
Padding(
padding: const EdgeInsets.only(right: 4),
child: _pill(
tokens,
label: label == 'All' ? t('graph.filter.all', 'All') : label,
selected: f.depth == depth,
onTap: () => _setDepth(depth),
),
),
],
),
),
if (tags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 10, right: 10, top: 6),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
for (final tag in tags)
_pill(
tokens,
label: f.excludeTags.contains(tag) ? '$tag' : (f.includeTags.contains(tag) ? '+$tag' : tag),
selected: f.includeTags.contains(tag),
danger: f.excludeTags.contains(tag),
onTap: () => _cycleTag(tag),
),
],
),
),
],
),
);
}
Widget _pill(SurfaceTokens tokens, {required String label, required bool selected, bool danger = false, required VoidCallback onTap}) {
final accent = danger ? tokens.statusError : tokens.globalFocus;
final active = selected || danger;
return ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? accent.withValues(alpha: 0.16) : (hovered ? tokens.sidebarItemHover : null),
border: Border.all(color: active ? accent : tokens.globalBorder),
borderRadius: BorderRadius.circular(10),
),
child: ClideText(label, fontSize: clideFontCaption, color: active ? accent : tokens.globalForeground),
),
);
}
}
+109
View File
@@ -0,0 +1,109 @@
/// The interactive vault-graph view (T-323): lays a [VaultGraph] out with the
/// force solver, paints it via [GraphPainter], and wires hover (highlight the
/// neighbourhood) + click (open the note). Pan/zoom + filtering layer on later.
library;
import 'package:clide/builtin/graph/src/graph_painter.dart';
import 'package:clide/src/graph/force_layout.dart';
import 'package:clide/src/graph/vault_graph.dart';
import 'package:clide/widgets/src/clide_settings.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
class GraphView extends StatefulWidget {
const GraphView({super.key, required this.graph, this.onOpen, this.layoutSize = const Size(800, 600)});
final VaultGraph graph;
/// Called with a node's id (its vault-relative path) when it's clicked.
final void Function(String nodeId)? onOpen;
final Size layoutSize;
@override
State<GraphView> createState() => _GraphViewState();
}
class _GraphViewState extends State<GraphView> {
static const double _minZoom = 0.2, _maxZoom = 5;
late Map<String, GraphPoint> _pos;
String? _hovered;
double _zoom = 1;
Offset _pan = Offset.zero;
@override
void initState() {
super.initState();
_relayout();
}
@override
void didUpdateWidget(GraphView old) {
super.didUpdateWidget(old);
if (!identical(old.graph, widget.graph)) _relayout();
}
void _relayout() {
_pos = ForceLayout.compute(
[for (final n in widget.graph.nodes) n.id],
widget.graph.edgePairs,
width: widget.layoutSize.width,
height: widget.layoutSize.height,
);
_hovered = null;
// A fresh graph re-fits; drop any user pan/zoom.
_zoom = 1;
_pan = Offset.zero;
}
void _onScroll(PointerScrollEvent e) {
final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9;
final next = (_zoom * factor).clamp(_minZoom, _maxZoom);
if (next != _zoom) setState(() => _zoom = next);
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return LayoutBuilder(
builder: (ctx, constraints) {
final size = constraints.biggest;
String? hit(Offset local) => hitTestNode(widget.graph, _pos, local, size, layoutSize: widget.layoutSize, zoom: _zoom, pan: _pan);
return Listener(
onPointerSignal: (s) {
if (s is PointerScrollEvent) _onScroll(s);
},
child: MouseRegion(
onHover: (e) {
final h = hit(e.localPosition);
if (h != _hovered) setState(() => _hovered = h);
},
onExit: (_) {
if (_hovered != null) setState(() => _hovered = null);
},
child: GestureDetector(
onTapUp: (d) {
final h = hit(d.localPosition);
if (h != null) widget.onOpen?.call(h);
},
onPanUpdate: (d) => setState(() => _pan += d.delta),
child: CustomPaint(
size: size,
painter: GraphPainter(
graph: widget.graph,
positions: _pos,
tokens: tokens,
highlight: _hovered == null ? null : widget.graph.neighborhood(_hovered!),
layoutSize: widget.layoutSize,
zoom: _zoom,
pan: _pan,
),
),
),
),
);
},
);
}
}
@@ -1,3 +1,4 @@
import 'package:clide/builtin/tools_settings/src/path_preset_control.dart';
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
@@ -7,6 +8,9 @@ import 'package:clide/src/env/supporter_binaries.dart';
/// claude, d2, … (D-104 / T-495). A path field per tool (`app.tools.<name>`),
/// auto-detected on first run, plus a Re-detect action. Editing a path rebuilds
/// the live resolver so the change takes effect without a restart.
///
/// Also hosts the per-workspace PATH preset editor (D-106 / T-511) — the UI
/// half of `clide env path …`.
class ToolsSettingsExtension extends ClideExtension {
@override
String get id => 'builtin.tools-settings';
@@ -87,9 +91,31 @@ class ToolsSettingsExtension extends ClideExtension {
),
],
),
// Per-workspace PATH preset (D-106, T-511): dirs prepended to the
// PATH of every shell clide spawns for this repo. CLI parity:
// `clide env path list|set|add|remove|clear|capture`.
SettingsSection(
label: 'Workspace PATH',
labelKey: 'settings.section.path',
fields: [
SettingsField(
// Placeholder key — the custom control persists to the
// worktree-aware app.env.pathPrepend.<hash> key itself.
key: 'app.env.pathPrepend',
kind: SettingsFieldKind.custom,
label: 'Prepend to PATH',
labelKey: 'settings.field.pathPreset.label',
help:
'Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo\'s preset. Applies to new shells.',
helpKey: 'settings.field.pathPreset.help',
customId: 'tools.path-preset',
),
],
),
],
),
),
SettingsControlContribution(id: 'tools.path-preset', customId: 'tools.path-preset', builder: (_) => const PathPresetControl()),
];
Future<IpcResponse> _redetect(List<String> args) async {
@@ -0,0 +1,334 @@
import 'dart:io' show Directory, Platform;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel;
import 'package:clide/src/env/path_preset.dart';
import 'package:clide/src/env/shell_env.dart' show loginShellPathOrNull;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
const _ns = 'builtin.tools-settings';
String _s(BuildContext context, String key, String placeholder) => ClideSettings.i18n.string(context, key, namespace: _ns, placeholder: placeholder);
/// Settings control for the per-workspace PATH preset (D-106, T-511): an
/// ordered list of directories prepended to the PATH of everything clide
/// spawns for this repo — hosted Claude sessions and terminal panes alike.
///
/// The preset keys off the REPO identity ([presetRootFor]): opened from a
/// linked worktree (e.g. `.worktrees/<name>`) the control edits the main
/// repo's preset and says so. Writes go to the user-scope settings key the
/// `clide env path` verbs use, published on [envPathChannel] (D-6 parity);
/// the store notifier keeps this control live for CLI edits.
class PathPresetControl extends StatefulWidget {
const PathPresetControl({super.key});
@override
State<PathPresetControl> createState() => _PathPresetControlState();
}
class _PathPresetControlState extends State<PathPresetControl> {
final TextEditingController _entry = TextEditingController();
final FocusNode _focus = FocusNode(debugLabel: 'add-path-entry');
SettingsStore? _settings;
/// Capture-from-login-shell results; null until the button is pressed.
List<String>? _suggestions;
bool _addRejected = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final settings = ClideKernel.maybeOf(context)?.settings;
if (identical(settings, _settings)) return;
_settings?.removeListener(_onChange);
_settings = settings;
_settings?.addListener(_onChange);
}
void _onChange() {
if (mounted) setState(() {});
}
@override
void dispose() {
_settings?.removeListener(_onChange);
_entry.dispose();
_focus.dispose();
super.dispose();
}
List<String> _dirs(KernelServices services, String cwd) => presetDirsFrom((k) => services.settings.get<Object>(k), cwd);
Future<void> _write(KernelServices services, String cwd, List<String> dirs, String action) async {
final key = pathPresetKey(cwd);
final write = dirs.isEmpty ? services.settings.removeAt(SettingsScope.app, key) : services.settings.setAt(SettingsScope.app, key, dirs);
services.messages.publish('ui', envPathChannel, {'action': action, 'root': presetRootFor(cwd), 'dirs': dirs});
await write;
}
/// Expand a leading `~/`, require an absolute path (mirrors the `env path`
/// verb's guard), strip trailing slashes. Null = rejected.
String? _normalizeEntry(String raw) {
var d = raw.trim();
if (d == '~' || d.startsWith('~/')) {
final home = Platform.environment['HOME'];
if (home == null || home.isEmpty) return null;
d = d == '~' ? home : '$home${d.substring(1)}';
}
if (d.isEmpty || !(d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d))) return null;
// One dir per entry — an embedded PATH separator would smuggle extra
// (possibly empty → CWD) tokens into the joined PATH.
final body = RegExp(r'^[A-Za-z]:').hasMatch(d) ? d.substring(2) : d;
if (body.contains(':') || body.contains(';')) return null;
while (d.length > 1 && d.endsWith('/')) {
d = d.substring(0, d.length - 1);
}
return d;
}
Future<void> _add(KernelServices services, String cwd, [String? suggestion]) async {
final raw = suggestion ?? _entry.text;
if (raw.trim().isEmpty) return;
final d = _normalizeEntry(raw);
if (d == null) {
setState(() => _addRejected = true);
return;
}
final dirs = _dirs(services, cwd);
if (suggestion == null) _entry.clear();
setState(() {
_addRejected = false;
_suggestions?.remove(raw);
_suggestions?.remove(d);
});
if (dirs.contains(d)) return;
await _write(services, cwd, [...dirs, d], 'add');
}
Future<void> _move(KernelServices services, String cwd, int index, int delta) async {
final dirs = _dirs(services, cwd);
final to = index + delta;
if (to < 0 || to >= dirs.length) return;
final out = [...dirs];
final d = out.removeAt(index);
out.insert(to, d);
await _write(services, cwd, out, 'set');
}
Future<void> _remove(KernelServices services, String cwd, String dir) async {
final kept = _dirs(services, cwd).where((d) => d != dir).toList();
await _write(services, cwd, kept, 'remove');
}
void _capture(List<String> current) {
final missing = missingLoginShellDirs(loginPath: loginShellPathOrNull(), processPath: Platform.environment['PATH'] ?? '');
setState(() => _suggestions = missing.where((d) => !current.contains(d)).toList());
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final services = ClideKernel.maybeOf(context);
final cwd = services?.settings.projectDir?.path;
if (services == null || cwd == null) {
return ClideText(_s(context, 'path.noWorkspace', 'Open a workspace to set its PATH preset.'), fontSize: clideFontCaption, color: tokens.globalTextMuted);
}
final root = presetRootFor(cwd);
final dirs = _dirs(services, cwd);
final suggestions = _suggestions;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (root != cwd)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
ClideText(_s(context, 'path.sharedRoot', 'Worktree — preset shared with'), fontSize: clideFontCaption, color: tokens.globalTextMuted),
const SizedBox(width: 6),
Expanded(
child: ClideText(
root,
fontSize: clideFontCaption,
muted: true,
fontFamily: ClideSettings.fonts.monoOf(context),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (dirs.isEmpty)
ClideText(
_s(context, 'path.empty', 'No preset entries — spawned shells get the resolved login-shell PATH as-is.'),
fontSize: clideFontCaption,
color: tokens.globalTextMuted,
)
else
for (var i = 0; i < dirs.length; i++) _row(context, services, cwd, tokens, dirs, i),
const SizedBox(height: 10),
_addRow(context, services, cwd, tokens),
if (_addRejected)
Padding(
padding: const EdgeInsets.only(top: 4),
child: ClideText(_s(context, 'path.invalid', 'Enter an absolute path (or ~/…).'), fontSize: clideFontCaption, color: tokens.statusError),
),
const SizedBox(height: 10),
_captureButton(context, tokens, dirs),
if (suggestions != null && suggestions.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: ClideText(
_s(context, 'path.captureNone', 'Nothing to suggest — the login-shell PATH is already covered.'),
fontSize: clideFontCaption,
color: tokens.globalTextMuted,
),
),
if (suggestions != null)
for (final d in suggestions) _suggestionRow(context, services, cwd, tokens, d),
],
);
}
Widget _row(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens, List<String> dirs, int index) {
final dir = dirs[index];
final exists = Directory(dir).existsSync();
final removeLabel = _s(context, 'path.remove', 'Remove');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: ClideText(
dir,
fontSize: clideFontMono,
fontFamily: ClideSettings.fonts.monoOf(context),
color: exists ? tokens.globalForeground : tokens.statusWarning,
overflow: TextOverflow.ellipsis,
),
),
if (!exists) ...[
const SizedBox(width: 6),
ClideText(_s(context, 'path.missing', 'missing'), fontSize: clideFontCaption, color: tokens.statusWarning),
],
const SizedBox(width: 8),
_iconButton(context, 'arrow-up', '${_s(context, 'path.moveUp', 'Move up')}: $dir', index == 0 ? null : () => _move(services, cwd, index, -1)),
const SizedBox(width: 6),
_iconButton(
context,
'arrow-down',
'${_s(context, 'path.moveDown', 'Move down')}: $dir',
index == dirs.length - 1 ? null : () => _move(services, cwd, index, 1),
),
const SizedBox(width: 6),
_iconButton(context, 'trash', '$removeLabel: $dir', () => _remove(services, cwd, dir), color: tokens.statusError),
],
),
);
}
Widget _suggestionRow(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens, String dir) {
final addLabel = _s(context, 'path.addSuggestion', 'Add suggested entry');
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
_iconButton(context, 'plus-circle', '$addLabel: $dir', () => _add(services, cwd, dir), color: tokens.statusSuccess),
const SizedBox(width: 8),
Expanded(
child: ClideText(dir, fontSize: clideFontMono, fontFamily: ClideSettings.fonts.monoOf(context), muted: true, overflow: TextOverflow.ellipsis),
),
],
),
);
}
Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) {
final tokens = ClideSettings.theme.of(context).surface;
final enabledColor = color ?? tokens.globalForeground;
return Semantics(
button: true,
enabled: onTap != null,
label: semantic,
excludeSemantics: true,
child: ClideTappable(
cursor: onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
onTap: onTap,
builder: (ctx, hovered, _) =>
ClideIcon(PhosphorIcons.byName(icon), size: 14, color: onTap == null ? tokens.globalTextMuted : (hovered ? enabledColor : tokens.globalTextMuted)),
),
);
}
Widget _addRow(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens) {
final addLabel = _s(context, 'path.add', 'Add entry');
return Row(
children: [
Expanded(
child: Container(
height: 26,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: EditableText(
controller: _entry,
focusNode: _focus,
style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
maxLines: 1,
onSubmitted: (_) => _add(services, cwd),
),
),
),
const SizedBox(width: 8),
Semantics(
button: true,
label: addLabel,
excludeSemantics: true,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: () => _add(services, cwd),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(color: hovered ? tokens.buttonHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
child: ClideText(addLabel, color: tokens.buttonForeground, fontSize: clideFontCaption),
),
),
),
],
);
}
Widget _captureButton(BuildContext context, SurfaceTokens tokens, List<String> dirs) {
final label = _s(context, 'path.capture', 'Suggest from login shell');
return Row(
children: [
Semantics(
button: true,
label: label,
excludeSemantics: true,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: () => _capture(dirs),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : tokens.panelBackground,
border: Border.all(color: tokens.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(label, color: tokens.globalForeground, fontSize: clideFontCaption),
),
),
),
],
);
}
}
+5 -1
View File
@@ -12,6 +12,10 @@ import 'package:ffi/ffi.dart' as pkg_ffi;
import 'src/pty/ffi/libc.dart' as libc;
/// The hand-built helper binary the probe spawns. Not part of the repo — the
/// harness skips this probe when it is absent.
const fdCheckHelperPath = '/tmp/checkfd';
/// Probe whether `Process.start` inherits socket fds (the macOS question the
/// testmode harness answers). Returns a human-readable result line.
Future<String> fdInheritanceCheck() async {
@@ -20,7 +24,7 @@ Future<String> fdInheritanceCheck() async {
final parent = sv[0];
final child = sv[1];
pkg_ffi.calloc.free(sv);
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final proc = await Process.start(fdCheckHelperPath, [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final stderr = await proc.stderr.transform(utf8.decoder).join();
final exit = await proc.exitCode;
libc.close(parent);
+3
View File
@@ -1,4 +1,7 @@
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
library;
/// Mirrors [fd_check_io.dart]; never exists on web, so the probe is skipped.
const fdCheckHelperPath = '/tmp/checkfd';
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
+5 -1
View File
@@ -1,6 +1,7 @@
/// The single dispatch point for opening a workspace file the way
/// clide routes file activations (T-51 / T-187):
/// * `.md` paths → the markdown reader, via the kernel MessageBus;
/// * `.canvas` paths → the canvas pane, via the kernel MessageBus (T-322);
/// * every other path → the editor, via the `editor.open` IPC verb.
///
/// Records the open in [KernelServices.recentFiles] so the quick-open
@@ -15,8 +16,11 @@ import 'package:clide/kernel/src/facade.dart';
void openWorkspaceFile(KernelServices services, String path) {
if (path.isEmpty) return;
services.recentFiles.push(path);
if (path.toLowerCase().endsWith('.md')) {
final lower = path.toLowerCase();
if (lower.endsWith('.md')) {
services.messages.publish('builtin.markdown', 'selection', {'path': path});
} else if (lower.endsWith('.canvas')) {
services.messages.publish('builtin.canvas', 'selection', {'path': path});
} else {
unawaited(services.ipc.request('editor.open', args: {'path': path}));
}
+8 -1
View File
@@ -282,7 +282,14 @@ void _emit(StringBuffer buf, Object? v, int indent) {
return;
}
v.forEach((k, vv) {
buf.write('$pad$k:');
// Keys go through the same quoting as values: a numeric-shaped key —
// e.g. an all-digit FNV workspace-hash suffix (`app.env.pathPrepend.
// 0123…`, `app.claude.account.<hash>`) — would otherwise reload as an
// int/float and silently corrupt the key (leading zero dropped, or
// `1e…` collapsing to Infinity), orphaning the stored value.
buf.write(pad);
_emitScalar(buf, '$k');
buf.write(':');
if (vv is Map && vv.isNotEmpty) {
buf.writeln();
_emit(buf, vv, indent + 1);
+46 -2
View File
@@ -40,6 +40,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/env_path_commands.dart';
import 'package:clide/src/daemon/draw_commands.dart';
import 'package:clide/src/draw/compare_template.dart' show compareTemplateHandler;
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
@@ -62,7 +63,8 @@ import 'package:clide/src/daemon/search_commands.dart';
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath;
import 'package:clide/src/env/path_preset.dart' show applyPathPreset, pathPresetKey, presetDirsFrom, presetLookupRoot;
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath, resolvedToolPath;
import 'package:clide/src/env/supporter_binaries.dart';
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart' show kPhosphorGlyphs;
import 'package:clide/src/ipc/envelope.dart';
@@ -310,7 +312,19 @@ Future<void> main() async {
crumbPath: '${logDirectory()}/clide-pty.crumbs.log',
verbose: log.minLevel.index <= LogLevel.debug.index,
);
final paneRegistry = PaneRegistry(events: eventSink, ptyLog: ptyLog);
// Terminal PTY children get the workspace's PATH preset (D-106) prepended
// to the resolved login-shell PATH. Read live per spawn off the kernel
// settings (null pre-boot → plain resolved PATH, same as before).
final paneRegistry = PaneRegistry(
events: eventSink,
ptyLog: ptyLog,
pathForSpawn: (cwd) {
final settings = kernelSettings;
final base = resolvedToolPath();
if (settings == null) return base;
return applyPathPreset(base, presetDirsFrom((k) => settings.get<Object>(k), presetLookupRoot(cwd, workRoot.path)));
},
);
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
// visible to `pane list` by snapshotting the kernel PanelRegistry +
// LayoutArrangement at request time — no mirrored state to drift.
@@ -440,6 +454,18 @@ Future<void> main() async {
publisher: () => kernelMessages?.publish,
workspaceCwd: () => workRoot.path,
);
// `clide env path …` — the per-workspace PATH preset's CLI half (D-106,
// T-511). Reads/writes the user-scope preset key through the kernel
// settings; mutations publish on envPathChannel.
registerEnvPathCommands(
dispatcher,
() {
final settings = kernelSettings;
return settings == null ? null : _PathPresetStoreAdapter(settings);
},
publisher: () => kernelMessages?.publish,
workspaceCwd: () => workRoot.path,
);
// `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
@@ -655,6 +681,24 @@ class _AccountStoreAdapter implements AccountStore {
Future<void> unbind(String cwd) => _reg.unbindWorkspace(cwd);
}
/// Adapts the (foundation-bound) [SettingsStore] to the Flutter-free
/// [PathPresetStore] port the `env path` verbs use (D-106, T-511). The key is
/// worktree-aware ([pathPresetKey]); an empty preset removes the key rather
/// than leaving an empty list in settings.yaml.
class _PathPresetStoreAdapter implements PathPresetStore {
_PathPresetStoreAdapter(this._settings);
final SettingsStore _settings;
@override
List<String> dirsFor(String cwd) => presetDirsFrom((k) => _settings.get<Object>(k), cwd);
@override
Future<void> setFor(String cwd, List<String> dirs) {
final key = pathPresetKey(cwd);
return dirs.isEmpty ? _settings.removeAt(SettingsScope.app, key) : _settings.setAt(SettingsScope.app, key, dirs);
}
}
class _BusEventSink implements DaemonEventSink {
_BusEventSink(this._bus);
final DaemonBus _bus;
+287
View File
@@ -0,0 +1,287 @@
/// The Obsidian JSONCanvas format (`.canvas` files) as a pure Dart model
/// (T-322). Parses the on-disk JSON into typed nodes + edges and serialises
/// back, so the canvas pane can load, edit, and persist a `.canvas`.
///
/// Per D-91 `.canvas` is an import format, not clide's native schema — this
/// model is the faithful parse. The interactive pane paints this model
/// directly via `CanvasPainter` (the D-103 live-widget exception, like the
/// graph); only the display-only drawing card lowers to the SVG substrate.
/// Pure data — no Flutter, no I/O here — so it runs under `dart test`.
/// Spec: https://jsoncanvas.org/spec/1.0/
library;
import 'dart:convert';
/// Which edge of a node an edge attaches to.
enum CanvasSide {
top,
right,
bottom,
left;
static CanvasSide? parse(Object? v) => switch (v) {
'top' => top,
'right' => right,
'bottom' => bottom,
'left' => left,
_ => null,
};
String get wire => name;
}
/// The endpoint decoration of an edge. Obsidian defaults `fromEnd` to none and
/// `toEnd` to arrow.
enum CanvasEnd {
none,
arrow;
static CanvasEnd parse(Object? v, CanvasEnd fallback) => switch (v) {
'none' => none,
'arrow' => arrow,
_ => fallback,
};
String get wire => name;
}
/// How a group node's background image is laid out.
enum CanvasBackgroundStyle {
cover,
ratio,
repeat;
static CanvasBackgroundStyle? parse(Object? v) => switch (v) {
'cover' => cover,
'ratio' => ratio,
'repeat' => repeat,
_ => null,
};
String get wire => name;
}
/// A node in a canvas. Every node has an id, a position, a size, and an
/// optional [color] (a preset `"1".."6"` or a `#rrggbb` hex, per the spec).
sealed class CanvasNode {
const CanvasNode({required this.id, required this.x, required this.y, required this.width, required this.height, this.color});
final String id;
final double x, y, width, height;
final String? color;
/// The `type` discriminator written to disk.
String get type;
Map<String, Object?> toJson();
Map<String, Object?> _base() => {'id': id, 'type': type, 'x': x, 'y': y, 'width': width, 'height': height, if (color != null) 'color': color};
}
/// A free-text node holding a markdown string.
class TextNode extends CanvasNode {
const TextNode({required super.id, required super.x, required super.y, required super.width, required super.height, super.color, this.text = ''});
final String text;
@override
String get type => 'text';
@override
Map<String, Object?> toJson() => {..._base(), 'text': text};
}
/// An embedded vault file, optionally scrolled to a [subpath] heading/block.
class FileNode extends CanvasNode {
const FileNode({
required super.id,
required super.x,
required super.y,
required super.width,
required super.height,
super.color,
required this.file,
this.subpath,
});
final String file;
final String? subpath;
@override
String get type => 'file';
@override
Map<String, Object?> toJson() => {..._base(), 'file': file, if (subpath != null) 'subpath': subpath};
}
/// An external URL card.
class LinkNode extends CanvasNode {
const LinkNode({required super.id, required super.x, required super.y, required super.width, required super.height, super.color, required this.url});
final String url;
@override
String get type => 'link';
@override
Map<String, Object?> toJson() => {..._base(), 'url': url};
}
/// A labelled rectangle that visually groups the nodes inside it. May carry a
/// background image.
class GroupNode extends CanvasNode {
const GroupNode({
required super.id,
required super.x,
required super.y,
required super.width,
required super.height,
super.color,
this.label,
this.background,
this.backgroundStyle,
});
final String? label;
final String? background;
final CanvasBackgroundStyle? backgroundStyle;
@override
String get type => 'group';
@override
Map<String, Object?> toJson() => {
..._base(),
if (label != null) 'label': label,
if (background != null) 'background': background,
if (backgroundStyle != null) 'backgroundStyle': backgroundStyle!.wire,
};
}
/// A directed connection between two nodes, optionally anchored to a specific
/// [fromSide]/[toSide] and decorated with end caps, a [color], and a [label].
class CanvasEdge {
const CanvasEdge({
required this.id,
required this.fromNode,
required this.toNode,
this.fromSide,
this.toSide,
this.fromEnd = CanvasEnd.none,
this.toEnd = CanvasEnd.arrow,
this.color,
this.label,
});
final String id;
final String fromNode, toNode;
final CanvasSide? fromSide, toSide;
final CanvasEnd fromEnd, toEnd;
final String? color;
final String? label;
static CanvasEdge? _parse(Map<String, Object?> m) {
final id = m['id'], from = m['fromNode'], to = m['toNode'];
if (id is! String || from is! String || to is! String) return null;
return CanvasEdge(
id: id,
fromNode: from,
toNode: to,
fromSide: CanvasSide.parse(m['fromSide']),
toSide: CanvasSide.parse(m['toSide']),
fromEnd: CanvasEnd.parse(m['fromEnd'], CanvasEnd.none),
toEnd: CanvasEnd.parse(m['toEnd'], CanvasEnd.arrow),
color: m['color'] as String?,
label: m['label'] as String?,
);
}
Map<String, Object?> toJson() => {
'id': id,
'fromNode': fromNode,
if (fromSide != null) 'fromSide': fromSide!.wire,
'toNode': toNode,
if (toSide != null) 'toSide': toSide!.wire,
// Only emit end caps when they differ from the spec defaults.
if (fromEnd != CanvasEnd.none) 'fromEnd': fromEnd.wire,
if (toEnd != CanvasEnd.arrow) 'toEnd': toEnd.wire,
if (color != null) 'color': color,
if (label != null) 'label': label,
};
}
/// A parsed `.canvas` document: its [nodes] and [edges].
class CanvasDoc {
const CanvasDoc({this.nodes = const [], this.edges = const []});
final List<CanvasNode> nodes;
final List<CanvasEdge> edges;
bool get isEmpty => nodes.isEmpty && edges.isEmpty;
/// Parse `.canvas` JSON text. Malformed JSON, or a top level that isn't an
/// object, throws [FormatException]; individual nodes/edges that are
/// unrecognised or missing required fields are skipped, not fatal.
factory CanvasDoc.parse(String source) {
final decoded = source.trim().isEmpty ? const <String, Object?>{} : jsonDecode(source);
if (decoded is! Map) throw const FormatException('canvas: top level must be a JSON object');
return CanvasDoc.fromJson(decoded.cast<String, Object?>());
}
factory CanvasDoc.fromJson(Map<String, Object?> m) {
final nodes = <CanvasNode>[];
for (final raw in (m['nodes'] as List? ?? const [])) {
if (raw is Map) {
final node = _parseNode(raw.cast<String, Object?>());
if (node != null) nodes.add(node);
}
}
final edges = <CanvasEdge>[];
for (final raw in (m['edges'] as List? ?? const [])) {
if (raw is Map) {
final edge = CanvasEdge._parse(raw.cast<String, Object?>());
if (edge != null) edges.add(edge);
}
}
return CanvasDoc(nodes: nodes, edges: edges);
}
/// Serialise back to the on-disk shape. Empty `nodes`/`edges` arrays are
/// always present, matching what Obsidian writes.
Map<String, Object?> toJson() => {
'nodes': [for (final n in nodes) n.toJson()],
'edges': [for (final e in edges) e.toJson()],
};
String encode() => const JsonEncoder.withIndent('\t').convert(toJson());
static CanvasNode? _parseNode(Map<String, Object?> m) {
final id = m['id'];
if (id is! String) return null;
final x = _num(m['x']), y = _num(m['y']), w = _num(m['width']), h = _num(m['height']);
if (x == null || y == null || w == null || h == null) return null;
final color = m['color'] as String?;
return switch (m['type']) {
'text' => TextNode(id: id, x: x, y: y, width: w, height: h, color: color, text: m['text'] as String? ?? ''),
'file' =>
m['file'] is String
? FileNode(id: id, x: x, y: y, width: w, height: h, color: color, file: m['file'] as String, subpath: m['subpath'] as String?)
: null,
'link' => m['url'] is String ? LinkNode(id: id, x: x, y: y, width: w, height: h, color: color, url: m['url'] as String) : null,
'group' => GroupNode(
id: id,
x: x,
y: y,
width: w,
height: h,
color: color,
label: m['label'] as String?,
background: m['background'] as String?,
backgroundStyle: CanvasBackgroundStyle.parse(m['backgroundStyle']),
),
_ => null, // unknown/unsupported node type — skip
};
}
static double? _num(Object? v) => v is num ? v.toDouble() : null;
}
+208
View File
@@ -0,0 +1,208 @@
/// Registers the `env path …` verbs — the CLI half of the per-workspace PATH
/// preset (D-106, T-511; D-6 CLI parity with the Tools settings section).
///
/// `clide env path list`
/// `clide env path set <dir> [<dir> …]`
/// `clide env path add <dir> [<dir> …]`
/// `clide env path remove <dir> [<dir> …]`
/// `clide env path clear`
/// `clide env path capture`
///
/// The argv grammar splits the first two tokens as `subsystem.verb`, so the
/// command id is `env.path` and the sub-verb arrives as the first positional;
/// the trailing `dirs` positional is variadic. Preset reads/writes go through
/// an injected [PathPresetStore] port and mutations are published on
/// [envPathChannel] — keeping this handler Flutter-free so it runs under
/// `dart test`.
///
/// Preset dirs deliberately live OUTSIDE the workspace (`~/.nvm/…`, linuxbrew),
/// so there is no workspace-confinement check here — the guards are absolute
/// paths only (after `~/` expansion) and the T-104 leading-dash rejection.
/// Nonexistent dirs warn (in the `missing` field), never error: a preset may
/// predate the dir it names.
library;
import 'dart:io' show Directory, Platform;
import '../env/path_preset.dart';
import '../env/shell_env.dart' show loginShellPathOrNull, resolvedToolPath;
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 preset mutations publish on; UI surfaces subscribe
/// to reflect a CLI edit (the settings control also re-reads off the store
/// notifier, so this is the event-contract half of D-6).
const envPathChannel = 'env.path';
/// Flutter-free port over the (foundation-bound) SettingsStore, injected so
/// this command runs under `dart test`. main.dart adapts the real store to it.
abstract class PathPresetStore {
/// The preset for the workspace at [cwd], resolved to its repo identity
/// ([presetRootFor]) — a worktree reads its main repo's preset.
List<String> dirsFor(String cwd);
/// Replace the preset for [cwd]'s repo. An empty [dirs] removes the key.
Future<void> setFor(String cwd, List<String> dirs);
}
/// Register `env.path`. [store] / [publisher] / [workspaceCwd] are late-bound
/// closures (captured post-boot in main.dart); each may be null in a headless
/// context, in which case the verb degrades to a clear error. [home] /
/// [dirExists] / [loginPath] / [processPath] are test seams over the real
/// environment.
void registerEnvPathCommands(
DaemonDispatcher d,
PathPresetStore? Function() store, {
MessagePublisher? Function()? publisher,
String? Function()? workspaceCwd,
String? Function()? home,
bool Function(String dir)? dirExists,
String? Function()? loginPath,
String Function()? processPath,
}) {
d.register(
'env.path',
(req) async => _dispatch(
req,
store(),
publisher?.call(),
workspaceCwd?.call(),
home: home ?? () => Platform.environment['HOME'],
dirExists: dirExists ?? (dir) => Directory(dir).existsSync(),
loginPath: loginPath ?? loginShellPathOrNull,
processPath: processPath ?? () => Platform.environment['PATH'] ?? '',
),
schema: const CommandSchema(
positional: ['action', 'dirs'],
args: {
'action': ArgSpec(required: true, rejectLeadingDash: true),
'dirs': ArgSpec(type: ArgType.stringList, rejectLeadingDash: true, maxItems: 64),
},
),
);
}
Future<IpcResponse> _dispatch(
IpcRequest req,
PathPresetStore? store,
MessagePublisher? publish,
String? cwd, {
required String? Function() home,
required bool Function(String dir) dirExists,
required String? Function() loginPath,
required String Function() processPath,
}) async {
if (store == null) return _err(req.id, 'settings unavailable in this context');
if (cwd == null) return _err(req.id, 'no workspace open');
final action = (req.args['action'] as String?)?.trim();
final rawDirs = (req.args['dirs'] as List?)?.cast<String>() ?? const <String>[];
final root = presetRootFor(cwd);
Future<IpcResponse> mutate(List<String> dirs) async {
await store.setFor(cwd, dirs);
publish?.call('cli', envPathChannel, {'action': action, 'root': root, 'dirs': dirs});
return _ok(req.id, {'root': root, 'dirs': dirs, 'missing': _missing(dirs, dirExists)});
}
switch (action) {
case 'list':
final dirs = store.dirsFor(cwd);
return _ok(req.id, {'root': root, 'dirs': dirs, 'missing': _missing(dirs, dirExists), 'effectivePath': applyPathPreset(resolvedToolPath(), dirs)});
case 'set':
if (rawDirs.isEmpty) return _err(req.id, 'set requires at least one <dir>', hint: 'to empty the preset use: clide env path clear');
final (dirs, bad) = _expandAll(rawDirs, home());
if (bad != null) return _err(req.id, bad, hint: 'preset entries must be absolute paths (or ~/…)');
return mutate(_dedupe(dirs));
case 'add':
if (rawDirs.isEmpty) return _err(req.id, 'add requires at least one <dir>');
final (dirs, bad) = _expandAll(rawDirs, home());
if (bad != null) return _err(req.id, bad, hint: 'preset entries must be absolute paths (or ~/…)');
return mutate(_dedupe([...store.dirsFor(cwd), ...dirs]));
case 'remove':
if (rawDirs.isEmpty) return _err(req.id, 'remove requires at least one <dir>');
final (dirs, bad) = _expandAll(rawDirs, home());
if (bad != null) return _err(req.id, bad, hint: 'pass the entry exactly as `env path list` shows it');
final current = store.dirsFor(cwd);
final drop = dirs.toSet();
final kept = current.where((d) => !drop.contains(d)).toList();
if (kept.length == current.length) {
return _err(req.id, 'no preset entry matches ${dirs.join(", ")}', hint: 'clide env path list');
}
return mutate(kept);
case 'clear':
return mutate(const []);
case 'capture':
final dirs = store.dirsFor(cwd);
final login = loginPath();
final proc = processPath();
final suggested = missingLoginShellDirs(loginPath: login, processPath: proc).where((d) => !dirs.contains(d)).toList();
return _ok(req.id, {
'root': root,
'suggested': suggested,
'loginShellPath': login,
'processPath': proc,
if (login == null) 'note': 'login-shell PATH probe unavailable; nothing to diff',
});
default:
return _err(
req.id,
'unknown env path action: ${action == null || action.isEmpty ? '(none)' : action}',
hint: 'use: list | set | add | remove | clear | capture',
);
}
}
/// Expand a leading `~/` against [home] and require absolute results. Returns
/// the expanded list, or an error message on the first bad entry.
(List<String>, String?) _expandAll(List<String> dirs, String? home) {
final out = <String>[];
for (final raw in dirs) {
var d = raw.trim();
if (d.isEmpty) return (out, 'empty preset entry');
if (d == '~' || d.startsWith('~/')) {
if (home == null || home.isEmpty) return (out, 'cannot expand "~" (no HOME)');
d = d == '~' ? home : '$home${d.substring(1)}';
}
final absolute = d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d);
if (!absolute) return (out, 'not an absolute path: $raw');
// One dir per entry: an embedded PATH separator would expand into extra
// tokens at join time — and a stray trailing ':' yields an EMPTY token,
// which POSIX shells resolve as CWD (the classic dot-in-PATH hazard).
final body = RegExp(r'^[A-Za-z]:').hasMatch(d) ? d.substring(2) : d;
if (body.contains(':') || body.contains(';')) return (out, 'entry contains a PATH separator: $raw');
while (d.length > 1 && d.endsWith('/')) {
d = d.substring(0, d.length - 1);
}
out.add(d);
}
return (out, null);
}
List<String> _dedupe(List<String> dirs) {
final out = <String>[];
for (final d in dirs) {
if (!out.contains(d)) out.add(d);
}
return out;
}
List<String> _missing(List<String> dirs, bool Function(String) dirExists) => [
for (final d in dirs)
if (!dirExists(d)) d,
];
IpcResponse _ok(String id, Map<String, Object?> data) => IpcResponse.ok(id: id, data: data);
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
id: id,
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
);
+1 -1
View File
@@ -84,7 +84,7 @@ void _writeScaffold(String dir, String name) {
// Claude with an empty CLAUDE.md stub. No language/framework templates. Never
// clobbers — safe to run over an existing folder (T-489).
final gitignore = File('$dir/.gitignore');
if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n');
if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n.worktrees/\n');
final claudeMd = File('$dir/CLAUDE.md');
if (!claudeMd.existsSync()) claudeMd.writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n');
}
+5 -3
View File
@@ -28,14 +28,16 @@ typedef MessagePublisher = void Function(String publisher, String channel, Map<S
/// The readers `ui.open` can target → (bus publisher id, payload key the
/// reader reads its entry from). Matches each reader's `ReaderNav` dataKey
/// (tickets/decisions key on `id`; markdown on `path`). `diff` is not a
/// ReaderNav reader — it keys on `path` and its extension subscribes to the
/// same `selection` channel to reveal its tab + focus the file (T-233).
/// (tickets/decisions key on `id`; markdown on `path`). `diff` and `canvas`
/// are not ReaderNav readers — they key on `path` and their extensions
/// subscribe to the same `selection` channel to reveal their tab + focus
/// the file (T-233, T-322).
const Map<String, ({String publisher, String dataKey})> _readers = {
'tickets': (publisher: 'builtin.tickets', dataKey: 'id'),
'decisions': (publisher: 'builtin.decisions', dataKey: 'id'),
'markdown': (publisher: 'builtin.markdown', dataKey: 'path'),
'diff': (publisher: 'builtin.diff', dataKey: 'path'),
'canvas': (publisher: 'builtin.canvas', dataKey: 'path'),
};
/// Severities the toast verb accepts — mirrors `ToastSeverity` (kept as a
+176
View File
@@ -0,0 +1,176 @@
/// Per-workspace PATH preset (D-106, T-511) — a machine-local, user-scope list
/// of directories prepended to the PATH clide hands to every shell it spawns:
/// the hosted Claude session (so the agent's Bash tool sees it) and terminal
/// PTY panes. The T-439 login-shell probe is a global heuristic with a known
/// hole (interactive-only profile additions like brew shellenv in `~/.bashrc`);
/// the preset is the explicit per-repo layer on top of it.
///
/// Storage is the app (user) settings layer keyed by workspace hash — the
/// account-binding pattern (T-483) — so absolute machine paths never land in a
/// committed file. The hash keys off the REPO identity, not the literal
/// directory: a linked git worktree (its `.git` is a `gitdir:` pointer file,
/// e.g. under an in-repo `.worktrees/` dir) resolves to the main repo root, so
/// every worktree shares its repo's preset.
///
/// Security fence: the preset feeds spawned-shell environments only. Clide's
/// own binary resolution (D-104 pins, bundled pql/git, the toolchain probe)
/// never consults it — see D-106.
///
/// Flutter-free (consumed by the `env path` daemon verbs under `dart test`).
library;
import 'dart:io';
import '../ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
/// Prefix of the user-scope settings key; the suffix is the preset-root hash.
const pathPresetKeyPrefix = 'app.env.pathPrepend.';
/// The settings key holding [workspaceRoot]'s preset — app layer, suffixed
/// with the FNV-1a hash (the same one D-70 derives for the socket path) of the
/// [presetRootFor]-resolved repo root, so all worktrees of a repo share one key.
String pathPresetKey(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile, bool Function(String path)? isDir}) =>
'$pathPresetKeyPrefix${fnv1a64Hex(canonicalWorkspaceKey(presetRootFor(workspaceRoot, isFile: isFile, readFile: readFile, isDir: isDir)))}';
/// The directory whose identity keys the preset: the MAIN repo root when
/// [workspaceRoot] is a linked git worktree, else [workspaceRoot] itself
/// (trailing separators stripped, so `/repo` and `/repo/` map alike).
///
/// A linked worktree's `.git` is a pointer FILE — `gitdir: <main>/.git/worktrees/<name>`
/// (git writes forward slashes on every platform; a relative
/// gitdir is resolved against the worktree root). Anything that doesn't match
/// that shape — a normal repo (`.git` directory), no `.git` at all, a
/// submodule pointer — keys off [workspaceRoot] unchanged.
///
/// The pointer content is REPO-controlled, so the resolved target is
/// validated before it is trusted: the candidate main root must actually
/// hold a `.git` directory (a genuine repo), else the pointer is ignored and
/// the workspace keys off itself. Without that check a crafted `.git` file
/// could alias an arbitrary path's preset key (same-user only — the preset
/// values themselves stay user-authored — but the boundary is cheap to hold).
///
/// [isFile]/[readFile]/[isDir] are injectable for tests; defaults touch the
/// real fs.
String presetRootFor(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile, bool Function(String path)? isDir}) {
final root = _stripTrailingSep(workspaceRoot);
final probe = isFile ?? _isFile;
final read = readFile ?? _readFile;
final dirProbe = isDir ?? _isDir;
final gitPointer = '$root/.git';
if (!probe(gitPointer)) return root;
final content = read(gitPointer);
if (content == null) return root;
final match = RegExp(r'^gitdir:\s*(.+?)\s*$', multiLine: true).firstMatch(content);
if (match == null) return root;
final gitdir = match.group(1)!.replaceAll(r'\', '/');
final resolved = _normalize(_isAbsolute(gitdir) ? gitdir : '${root.replaceAll(r'\', '/')}/$gitdir');
const marker = '/.git/worktrees/';
final idx = resolved.indexOf(marker);
if (idx <= 0) return root;
final mainRoot = resolved.substring(0, idx);
if (!dirProbe('$mainRoot/.git')) return root;
return mainRoot;
}
/// The root to key a spawn-time preset lookup on: [workspaceRoot] when [cwd]
/// is the workspace root or anywhere below it (a pane spawned in a subdir
/// must share the workspace's preset), else [cwd] itself (a spawn in an
/// unrelated directory keys off that directory's own repo).
String presetLookupRoot(String? cwd, String workspaceRoot) {
final ws = _stripTrailingSep(workspaceRoot);
if (cwd == null || cwd.isEmpty) return ws;
final c = _stripTrailingSep(cwd);
if (c == ws || c.startsWith('$ws/')) return ws;
return c;
}
/// Read [workspaceRoot]'s preset through an injected settings [read] (key →
/// stored value). Tolerant of a malformed value: anything that isn't a list of
/// non-empty strings is skipped, mirroring `AccountRegistry.accounts`.
List<String> presetDirsFrom(
Object? Function(String key) read,
String workspaceRoot, {
bool Function(String path)? isFile,
String? Function(String path)? readFile,
bool Function(String path)? isDir,
}) {
final raw = read(pathPresetKey(workspaceRoot, isFile: isFile, readFile: readFile, isDir: isDir));
if (raw is! List) return const [];
return [
for (final e in raw)
if (e is String && e.trim().isNotEmpty) e,
];
}
/// Pure prepend: [preset] dirs (de-duplicated, order kept) ahead of [base],
/// with base entries that repeat a preset dir dropped so the preset always
/// wins. An empty preset returns [base] unchanged.
///
/// Contract: one directory per entry. An entry containing [sep] is malformed
/// (the CLI/UI reject it at input time; this guards stored values that
/// predate the check) and is skipped — joined verbatim it would smuggle
/// extra tokens into PATH, and a trailing separator yields an EMPTY token,
/// which POSIX shells resolve as CWD.
String applyPathPreset(String base, List<String> preset, {String sep = ':'}) {
final dirs = <String>[];
for (final d in preset) {
final t = d.trim();
if (t.isNotEmpty && !t.contains(sep) && !dirs.contains(t)) dirs.add(t);
}
if (dirs.isEmpty) return base;
final baseParts = base.isEmpty ? const <String>[] : base.split(sep);
return [...dirs, ...baseParts.where((p) => !dirs.contains(p))].join(sep);
}
/// Capture-from-login-shell diff (D-106's `env path capture`): the entries the
/// login-shell PATH has that the process PATH lacks — the dirs a desktop
/// launch dropped, i.e. the preset candidates. Empty when the probe failed
/// ([loginPath] null/empty). Order-preserved, de-duplicated.
List<String> missingLoginShellDirs({required String? loginPath, required String processPath, String sep = ':'}) {
if (loginPath == null || loginPath.isEmpty) return const [];
final have = processPath.split(sep).toSet();
final out = <String>[];
for (final d in loginPath.split(sep)) {
if (d.isNotEmpty && !have.contains(d) && !out.contains(d)) out.add(d);
}
return out;
}
bool _isFile(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.file;
bool _isDir(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.directory;
String? _readFile(String path) {
try {
return File(path).readAsStringSync();
} catch (_) {
return null;
}
}
bool _isAbsolute(String p) => p.startsWith('/') || RegExp(r'^[A-Za-z]:/').hasMatch(p);
/// Segment-wise `.`/`..` normalization over forward-slash paths (a gitdir
/// pointer is often relative, e.g. `../../.git/worktrees/x`). No fs access.
String _normalize(String p) {
final drive = RegExp(r'^[A-Za-z]:').firstMatch(p)?.group(0) ?? '';
final rest = p.substring(drive.length);
final out = <String>[];
for (final seg in rest.split('/')) {
if (seg.isEmpty || seg == '.') continue;
if (seg == '..') {
if (out.isNotEmpty) out.removeLast();
continue;
}
out.add(seg);
}
return '$drive/${out.join('/')}';
}
String _stripTrailingSep(String p) {
var s = p;
while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) {
s = s.substring(0, s.length - 1);
}
return s;
}
+5
View File
@@ -80,6 +80,11 @@ String expandToolPath(String base, {required bool isMac, required bool isLinux,
return [...missing, ...existing].join(':');
}
/// The raw login-shell PATH the probe captured, or null when it is
/// unavailable (probe failed / not yet primed / Windows). `env path capture`
/// (D-106) diffs this against the process PATH to suggest preset entries.
String? loginShellPathOrNull() => _loginShellPath;
/// Test seam: force the cached login-shell PATH (and mark primed).
void debugSetLoginShellPath(String? value) {
_loginShellPath = value;
+3 -1
View File
@@ -162,5 +162,7 @@ class IgnoreSet {
/// ignore files. Matches D-004's "walker magic: none except
/// `.git/`" — but the tree-view UI benefits from hiding `.pql/` and
/// `.dart_tool/` too since users never edit those by hand.
static IgnoreSet builtin() => IgnoreSet.parse(const ['.git/\n.pql/\n.clide/\n.dart_tool/\nbuild/\nnode_modules/\n']);
/// `.worktrees/` holds in-repo linked worktrees (D-106) — sibling
/// checkouts a user opens as their own workspaces, not tree content.
static IgnoreSet builtin() => IgnoreSet.parse(const ['.git/\n.pql/\n.clide/\n.dart_tool/\n.worktrees/\nbuild/\nnode_modules/\n']);
}
+47
View File
@@ -0,0 +1,47 @@
/// Client-side filtering of a loaded [VaultGraph] (T-323): narrow to the notes
/// near the active one (depth-from-active), and/or by tag include/exclude.
///
/// Pure — the controller feeds in the already-queried tag map, so this runs
/// under `dart test`. The file glob is NOT here: a different glob is a
/// different file set, so it re-queries pql; these three refine what's already
/// loaded.
library;
import 'package:clide/src/graph/vault_graph.dart';
class GraphFilter {
const GraphFilter({this.depth, this.includeTags = const {}, this.excludeTags = const {}});
/// Hops from the active note to keep; null = the whole graph (no depth limit).
final int? depth;
/// Keep only notes tagged with at least one of these (empty = no filter).
final Set<String> includeTags;
/// Drop notes tagged with any of these.
final Set<String> excludeTags;
bool get isEmpty => depth == null && includeTags.isEmpty && excludeTags.isEmpty;
GraphFilter copyWith({int? depth, bool clearDepth = false, Set<String>? includeTags, Set<String>? excludeTags}) =>
GraphFilter(depth: clearDepth ? null : (depth ?? this.depth), includeTags: includeTags ?? this.includeTags, excludeTags: excludeTags ?? this.excludeTags);
/// Apply this filter to [full], using [tagsByPath] for the tag predicates and
/// [activePath] as the depth root. A depth filter with no active path (or one
/// that isn't a node) yields an empty graph — there's no local graph to show.
VaultGraph apply(VaultGraph full, {required Map<String, Set<String>> tagsByPath, String? activePath}) {
if (isEmpty) return full;
Set<String> tagsOf(String id) => tagsByPath[id] ?? const {};
var keep = {for (final n in full.nodes) n.id};
if (includeTags.isNotEmpty) {
keep = keep.where((id) => tagsOf(id).any(includeTags.contains)).toSet();
}
if (excludeTags.isNotEmpty) {
keep = keep.where((id) => !tagsOf(id).any(excludeTags.contains)).toSet();
}
if (depth != null) {
keep = activePath == null ? <String>{} : keep.intersection(full.nodesWithin(activePath, depth!));
}
return full.subgraph(keep);
}
}
Binary file not shown.
+12 -3
View File
@@ -19,10 +19,18 @@ import 'event_sink.dart';
import 'pane.dart';
class PaneRegistry {
PaneRegistry({required this.events, this.ptyLog = PtyLog.none});
PaneRegistry({required this.events, this.ptyLog = PtyLog.none, String Function(String? cwd)? pathForSpawn})
: _pathForSpawn = pathForSpawn ?? _defaultPathForSpawn;
final DaemonEventSink events;
/// PATH for a child spawned in `cwd` — the seam the per-workspace PATH
/// preset (D-106) is applied through; the default is the plain resolved
/// login-shell PATH (T-439). main.dart wires the preset-aware closure.
final String Function(String? cwd) _pathForSpawn;
static String _defaultPathForSpawn(String? _) => resolvedToolPath();
/// Breadcrumb hook handed to every PTY this registry spawns (T-434). Default
/// no-op; production wires it to the kernel Logger + a crumb file.
final PtyLog ptyLog;
@@ -55,8 +63,9 @@ class PaneRegistry {
final fullEnv = <String, String>{
...Platform.environment,
// The login-shell-resolved PATH so PTY children find user-installed tools
// even on a desktop launch (T-439); an explicit caller PATH still wins.
'PATH': resolvedToolPath(),
// even on a desktop launch (T-439), plus the workspace's PATH preset
// (D-106) via [_pathForSpawn]; an explicit caller PATH still wins.
'PATH': _pathForSpawn(cwd),
'TERM': 'xterm-256color',
'COLORTERM': 'truecolor',
'LANG': 'en_US.UTF-8',
+21 -2
View File
@@ -20,9 +20,12 @@ import 'package:flutter/foundation.dart' show compute;
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart';
import 'builtin/canvas/canvas.dart';
import 'builtin/diff/diff.dart';
import 'builtin/files/files.dart';
import 'builtin/git/git.dart';
import 'builtin/graph/graph.dart';
import 'builtin/pql/pql.dart';
import 'builtin/terminal/terminal.dart';
import 'extension/extension.dart' show ClideExtension;
import 'kernel/kernel.dart';
@@ -279,7 +282,17 @@ class _ClideTestAppState extends State<ClideTestApp> {
toolchain: tc,
);
final extensions = <ClideExtension>[DiffExtension(), FilesExtension(), GitExtension(), TerminalExtension()];
// PqlExtension precedes GraphExtension: the graph view depends on it
// (activateAll resolves the order, but this documents the dependency).
final extensions = <ClideExtension>[
DiffExtension(),
FilesExtension(),
GitExtension(),
TerminalExtension(),
PqlExtension(),
GraphExtension(),
CanvasExtension(),
];
for (final ext in extensions) {
try {
@@ -368,7 +381,13 @@ class _ClideTestAppState extends State<ClideTestApp> {
// Test: does Dart's Process.start inherit socket fds on macOS? (T-438: the
// FFI body lives in fd_check_io.dart so the web build can stub it out.)
await _testAsync('fd inheritance check', fdInheritanceCheck);
// The probe needs the hand-built /tmp/checkfd helper; on machines without
// it, skip explicitly rather than failing the whole harness.
if (File(fdCheckHelperPath).existsSync()) {
await _testAsync('fd inheritance check', fdInheritanceCheck);
} else {
_say('skip | fd inheritance check | $fdCheckHelperPath not present (hand-built probe helper — see fd_check_io.dart)');
}
_say('');
}
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the
extension framework.
publish_to: none
version: 2.9.0
version: 2.10.0
repository: https://github.com/postmeridiem/clide
# Short user-facing tagline (the welcome subtitle, web meta
# description, etc.). Baked into lib/src/build_info.g.dart by
@@ -0,0 +1,110 @@
/// CanvasExtension (T-322) contributes the canvas pane into the workspace
/// slot and routes `selection` messages (openWorkspaceFile,
/// `clide ui open canvas [path]`) into app-scoped document tabs — the
/// diff/T-233 pattern.
library;
import 'package:clide/builtin/canvas/canvas.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
/// Drain the broadcast-stream microtask hop between a bus publish/emit and
/// the extension's listener. Plain `test` body — no fake-async zone, so a
/// zero-duration timer fires normally.
Future<void> deliver() => Future<void>.delayed(Duration.zero);
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
test('declares one workspace tab with the canvas.view identity', () {
final tabs = CanvasExtension().contributions.whereType<TabContribution>().toList();
expect(tabs, hasLength(1));
final t = tabs.single;
expect(t.id, 'canvas.view');
expect(t.slot, Slots.workspace);
expect(t.title, 'Canvas');
expect(t.titleKey, 'tab.title');
expect(t.i18nNamespace, 'builtin.canvas');
});
test('registers into the workspace slot once activated', () async {
f.services.extensions.register(CanvasExtension());
await f.services.extensions.activate('builtin.canvas');
expect(f.services.panels.tabsFor(Slots.workspace).any((t) => t.id == 'canvas.view'), isTrue);
});
test('a selection message opens a document tab and reveals the pane', () async {
final ext = CanvasExtension();
f.services.extensions.register(ext);
await f.services.extensions.activate('builtin.canvas');
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'notes/board.canvas'});
await deliver();
expect(ext.openPaths, ['notes/board.canvas']);
expect(f.services.panels.activeTabIn(Slots.workspace), 'canvas.view');
});
test('re-selecting an open document focuses it instead of duplicating', () async {
final ext = CanvasExtension();
f.services.extensions.register(ext);
await f.services.extensions.activate('builtin.canvas');
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'b.canvas'});
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
await deliver();
expect(ext.openPaths, ['a.canvas', 'b.canvas']);
});
test('a malformed selection payload is ignored', () async {
final ext = CanvasExtension();
f.services.extensions.register(ext);
await f.services.extensions.activate('builtin.canvas');
f.services.messages.publish('builtin.canvas', 'selection', {'path': ''});
f.services.messages.publish('builtin.canvas', 'selection', {'nope': 1});
await deliver();
expect(ext.openPaths, isEmpty);
});
test('an in-place workspace switch drops the open documents (T-269)', () async {
final ext = CanvasExtension();
f.services.extensions.register(ext);
await f.services.extensions.activate('builtin.canvas');
f.services.events.emit(const ProjectOpened(path: '/repo/a'));
await deliver();
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
await deliver();
expect(ext.openPaths, ['a.canvas']);
// Same root again — documents stay.
f.services.events.emit(const ProjectOpened(path: '/repo/a'));
await deliver();
expect(ext.openPaths, ['a.canvas']);
// Different root — documents dropped.
f.services.events.emit(const ProjectOpened(path: '/repo/b'));
await deliver();
expect(ext.openPaths, isEmpty);
});
test('deactivate cancels the subscription and disposes the tabs', () async {
final ext = CanvasExtension();
f.services.extensions.register(ext);
await f.services.extensions.activate('builtin.canvas');
await ext.deactivate();
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
await deliver();
expect(ext.openPaths, isEmpty);
});
}
@@ -0,0 +1,113 @@
import 'dart:ui' as ui;
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:clide/widgets/src/clide_settings.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
CanvasDoc sampleDoc() => const CanvasDoc(
nodes: [
TextNode(id: 't', x: 0, y: 0, width: 200, height: 100, text: 'hello', color: '4'),
FileNode(id: 'f', x: 300, y: 0, width: 200, height: 100, file: 'notes/a.md'),
],
edges: [CanvasEdge(id: 'e', fromNode: 't', toNode: 'f', fromSide: CanvasSide.right, toSide: CanvasSide.left)],
);
Future<SurfaceTokens> tokensFrom(WidgetTester tester) async {
late SurfaceTokens tokens;
await tester.pumpWidget(
anchoredHarness(
f,
Builder(
builder: (ctx) {
tokens = ClideSettings.theme.of(ctx).surface;
return const SizedBox();
},
),
),
);
return tokens;
}
Future<bool> hasInk(CanvasPainter painter, [double s = 200]) async {
final rec = ui.PictureRecorder();
painter.paint(ui.Canvas(rec, Rect.fromLTWH(0, 0, s, s)), Size(s, s));
final img = await rec.endRecording().toImage(s.round(), s.round());
final data = (await img.toByteData())!;
for (var i = 3; i < data.lengthInBytes; i += 4) {
if (data.getUint8(i) != 0) return true;
}
return false;
}
group('canvasContentColor', () {
test('maps presets, hex (long + short), and null/unknown', () {
expect(canvasContentColor('4'), const Color(0xFF44CF6E));
expect(canvasContentColor('#ff0000'), const Color(0xFFFF0000));
expect(canvasContentColor('#f00'), const Color(0xFFFF0000));
expect(canvasContentColor(null), isNull);
expect(canvasContentColor('nonsense'), isNull);
});
});
group('CanvasBounds', () {
test('spans every node rect', () {
const doc = CanvasDoc(
nodes: [
TextNode(id: 'a', x: -10, y: 5, width: 20, height: 20, text: ''),
TextNode(id: 'b', x: 100, y: 0, width: 50, height: 80, text: ''),
],
);
final b = CanvasBounds.of(doc);
expect((b.left, b.top, b.right, b.bottom), (-10.0, 0.0, 150.0, 80.0));
});
});
group('CanvasViewport.fit', () {
test('centres content in the padded pane', () {
const doc = CanvasDoc(
nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')],
);
final vp = CanvasViewport.fit(const Size(400, 400), CanvasBounds.of(doc));
final r = vp.rectOf(doc.nodes.first);
expect(r.center.dx, closeTo(200, 0.001));
expect(r.center.dy, closeTo(200, 0.001));
});
});
testWidgets('paints nodes, edges, and a group', (tester) async {
final tokens = await tokensFrom(tester);
expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: sampleDoc(), tokens: tokens))), isTrue);
});
testWidgets('an empty canvas paints nothing (no throw)', (tester) async {
final tokens = await tokensFrom(tester);
expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: const CanvasDoc(), tokens: tokens))), isFalse);
});
testWidgets('a selected node still paints (draws the focus ring)', (tester) async {
final tokens = await tokensFrom(tester);
expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: sampleDoc(), tokens: tokens, selected: 't'))), isTrue);
});
testWidgets('repaints on zoom / pan / selection change, not when identical', (tester) async {
final tokens = await tokensFrom(tester);
final doc = sampleDoc();
CanvasPainter p({double zoom = 1, Offset pan = Offset.zero, String? selected}) =>
CanvasPainter(doc: doc, tokens: tokens, zoom: zoom, pan: pan, selected: selected);
expect(p().shouldRepaint(p()), isFalse);
expect(p(zoom: 2).shouldRepaint(p()), isTrue);
expect(p(pan: const Offset(1, 0)).shouldRepaint(p()), isTrue);
expect(p(selected: 't').shouldRepaint(p()), isTrue);
});
}
@@ -0,0 +1,119 @@
/// CanvasPaneHost (T-322): renders the extension-owned document tabs —
/// empty hint without documents, per-tab load through `files.read`, parse
/// errors surfaced as muted text, valid documents as an interactive
/// [CanvasView].
library;
import 'package:clide/builtin/canvas/canvas.dart';
import 'package:clide/builtin/canvas/src/canvas_view.dart';
import 'package:clide/clide.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
const _validCanvas = '{"nodes":[{"id":"n1","type":"text","text":"hi","x":0,"y":0,"width":100,"height":50}],"edges":[]}';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
// The shared harness's Overlay hands unbounded constraints; the pane is a
// Column with an Expanded body, so give it a tight box.
Widget host(MultitabController<String>? tabs) {
return harness(f, SizedBox(width: 800, height: 600, child: CanvasPaneHost(tabs: tabs)));
}
testWidgets('shows the empty hint before activation (null controller)', (tester) async {
await tester.pumpWidget(host(null));
expect(find.text('Open a .canvas file to view it here.'), findsOneWidget);
});
testWidgets('shows the empty hint when no document is open', (tester) async {
final tabs = MultitabController<String>();
addTearDown(tabs.dispose);
await tester.pumpWidget(host(tabs));
expect(find.text('Open a .canvas file to view it here.'), findsOneWidget);
});
testWidgets('loads a document through files.read and renders a CanvasView', (tester) async {
final reads = <Object?>[];
f.ipc.stub('files.read', (args) async {
reads.add(args['path']);
return IpcResponse.ok(id: '1', data: {'content': _validCanvas});
});
final tabs = MultitabController<String>(
initial: [const MultitabEntry(id: 'a.canvas', title: 'a.canvas', payload: 'a.canvas')],
);
addTearDown(tabs.dispose);
await tester.pumpWidget(host(tabs));
await pumpAsync(tester);
expect(reads, ['a.canvas']);
expect(find.byType(CanvasView), findsOneWidget);
expect(find.text('a.canvas'), findsOneWidget); // the sub-tab label
});
testWidgets('a failed read surfaces the IPC error as muted text', (tester) async {
f.ipc.stub('files.read', (args) async {
return IpcResponse.err(
id: '1',
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no such file: gone.canvas'),
);
});
final tabs = MultitabController<String>(
initial: [const MultitabEntry(id: 'gone.canvas', title: 'gone.canvas', payload: 'gone.canvas')],
);
addTearDown(tabs.dispose);
await tester.pumpWidget(host(tabs));
await pumpAsync(tester);
expect(find.text('no such file: gone.canvas'), findsOneWidget);
expect(find.byType(CanvasView), findsNothing);
});
testWidgets('a non-object top level surfaces the parse error', (tester) async {
f.ipc.stub('files.read', (args) async => IpcResponse.ok(id: '1', data: {'content': '[1,2,3]'}));
final tabs = MultitabController<String>(
initial: [const MultitabEntry(id: 'bad.canvas', title: 'bad.canvas', payload: 'bad.canvas')],
);
addTearDown(tabs.dispose);
await tester.pumpWidget(host(tabs));
await pumpAsync(tester);
expect(find.text('canvas: top level must be a JSON object'), findsOneWidget);
expect(find.byType(CanvasView), findsNothing);
});
testWidgets('two documents render as two sub-tabs, keep-alive across switches', (tester) async {
f.ipc.stub('files.read', (args) async => IpcResponse.ok(id: '1', data: {'content': _validCanvas}));
final tabs = MultitabController<String>(
initial: [
const MultitabEntry(id: 'notes/a.canvas', title: 'a.canvas', payload: 'notes/a.canvas'),
const MultitabEntry(id: 'notes/b.canvas', title: 'b.canvas', payload: 'notes/b.canvas'),
],
);
addTearDown(tabs.dispose);
await tester.pumpWidget(host(tabs));
await pumpAsync(tester);
expect(find.text('a.canvas'), findsOneWidget);
expect(find.text('b.canvas'), findsOneWidget);
// keepAlive keeps both bodies mounted — the inactive one offstage in
// the IndexedStack — so both must be found with skipOffstage off.
expect(find.byType(CanvasView, skipOffstage: false), findsNWidgets(2));
expect(find.byType(CanvasView), findsOneWidget);
tabs.activate('notes/b.canvas');
await pumpAsync(tester);
expect(find.byType(CanvasView, skipOffstage: false), findsNWidgets(2));
expect(find.byType(CanvasView), findsOneWidget);
});
}
+119
View File
@@ -0,0 +1,119 @@
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
import 'package:clide/builtin/canvas/src/canvas_view.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Widget view(CanvasDoc doc, {void Function(String?)? onSelect, double side = 400}) => anchoredHarness(
f,
SizedBox(
width: side,
height: side,
child: CanvasView(doc: doc, onSelect: onSelect),
),
);
group('hitTestCanvasNode', () {
test('finds the card under the point; cards beat the group behind them', () {
const doc = CanvasDoc(
nodes: [
GroupNode(id: 'g', x: 0, y: 0, width: 400, height: 400),
TextNode(id: 't', x: 150, y: 150, width: 100, height: 100, text: 'hi'),
],
);
// 400×400 doc into an 800×600 canvas, scale 1.5, centred. The text node's
// centre (200,200 in canvas coords) → the card wins over the group.
const size = Size(800, 600);
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc));
final centre = vp.rectOf(doc.nodes[1]).center;
expect(hitTestCanvasNode(doc, centre, size), 't');
// A point inside the group but outside the card hits the group.
final groupOnly = vp.rectOf(doc.nodes[0]).topLeft + const Offset(4, 4);
expect(hitTestCanvasNode(doc, groupOnly, size), 'g');
});
test('returns null off any node and on an empty doc', () {
const doc = CanvasDoc(
nodes: [TextNode(id: 't', x: 0, y: 0, width: 10, height: 10, text: '')],
);
expect(hitTestCanvasNode(doc, const Offset(-999, -999), const Size(400, 400)), isNull);
expect(hitTestCanvasNode(const CanvasDoc(), Offset.zero, const Size(400, 400)), isNull);
});
});
testWidgets('tapping a node selects it, tapping empty space clears it', (tester) async {
String? picked = '__unset__';
// One node fills the padded canvas, so its centre is the view centre.
const doc = CanvasDoc(
nodes: [TextNode(id: 'only', x: 0, y: 0, width: 100, height: 100, text: 'hi')],
);
await tester.pumpWidget(view(doc, onSelect: (id) => picked = id));
await tester.pump();
await tester.tap(find.byType(CanvasView)); // centre → the node
expect(picked, 'only');
await tester.tapAt(tester.getTopLeft(find.byType(CanvasView)) + const Offset(3, 3)); // padding → miss
expect(picked, isNull);
});
testWidgets('dragging pans; a reverse drag restores the hit location', (tester) async {
String? picked = '__unset__';
// Two small, spread-out nodes so each renders small enough that a pan moves
// it clear of its old screen spot (a single node would fill the pane).
const doc = CanvasDoc(
nodes: [
TextNode(id: 'a', x: 0, y: 0, width: 60, height: 60, text: 'a'),
TextNode(id: 'b', x: 400, y: 400, width: 60, height: 60, text: 'b'),
],
);
await tester.pumpWidget(view(doc, onSelect: (id) => picked = id));
await tester.pump();
final origin = tester.getTopLeft(find.byType(CanvasView));
final size = tester.getSize(find.byType(CanvasView));
final aSpot = origin + CanvasViewport.fit(size, CanvasBounds.of(doc)).rectOf(doc.nodes[0]).center;
await tester.tapAt(aSpot);
expect(picked, 'a'); // baseline hit
await tester.drag(find.byType(CanvasView), const Offset(140, 0));
await tester.pump();
await tester.tapAt(aSpot);
expect(picked, isNull); // node a panned away from its old spot
await tester.drag(find.byType(CanvasView), const Offset(-140, 0)); // equal, opposite → net zero
await tester.pump();
await tester.tapAt(aSpot);
expect(picked, 'a'); // panned back
});
testWidgets('a scroll signal zooms without throwing', (tester) async {
const doc = CanvasDoc(
nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')],
);
await tester.pumpWidget(view(doc));
await tester.pump();
final centre = tester.getCenter(find.byType(CanvasView));
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(centre));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120)));
await tester.pump();
expect(find.byType(CanvasView), findsOneWidget);
});
testWidgets('an empty canvas takes a tap without throwing', (tester) async {
await tester.pumpWidget(view(const CanvasDoc()));
await tester.pump();
await tester.tap(find.byType(CanvasView));
expect(find.byType(CanvasView), findsOneWidget);
});
}
@@ -67,6 +67,33 @@ void main() {
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: null, clideCliDir: '/opt/clide/bin');
expect(d['PATH'], '/opt/clide/bin');
});
test('exports PATH for a preset even when clide is already resolvable (D-106)', () {
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: null, prependDirs: ['/opt/go/bin']);
expect(d['PATH'], '/opt/go/bin:/usr/bin:/bin');
});
test('preset dirs come first, then the cli dir, then the current PATH (D-106)', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/usr/bin',
clideCliDir: '/home/dev/.local/bin',
prependDirs: ['/opt/go/bin', '/brew/bin'],
);
expect(d['PATH'], '/opt/go/bin:/brew/bin:/home/dev/.local/bin:/usr/bin');
});
test('a preset dir already on the current PATH is not duplicated (D-106)', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/opt/go/bin:/usr/bin',
clideCliDir: null,
prependDirs: ['/opt/go/bin'],
);
expect(d['PATH'], '/opt/go/bin:/usr/bin');
});
});
group('resolveClideCliDir (T-215)', () {
@@ -143,5 +170,16 @@ void main() {
final b = agentBootstrap('/ws', base: {'CLAUDE_CONFIG_DIR': '/override'}, boundConfigDir: (_) => '/bound');
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/override');
});
test('the workspace PATH preset lands at the head of the delta PATH (D-106)', () {
final b = agentBootstrap('/ws', pathPreset: (cwd) => cwd == '/ws' ? ['/opt/go/bin'] : const []);
expect(b.envDelta['PATH'], startsWith('/opt/go/bin:'));
});
test('no preset wired → bootstrap behaves as before (no gratuitous PATH export)', () {
final emptyPreset = agentBootstrap('/ws', pathPreset: (_) => const []);
final unwired = agentBootstrap('/ws');
expect(emptyPreset.envDelta.containsKey('PATH'), unwired.envDelta.containsKey('PATH'));
});
});
}
@@ -373,6 +373,39 @@ void main() {
await tester.pump();
expect(cycles, 0);
});
testWidgets('Ctrl+Shift+M fires onCycleModeFull, not onCycleMode (T-510)', (tester) async {
var safe = 0, full = 0;
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => safe++, onCycleModeFull: () => full++)));
await tester.tap(find.byType(EditableText));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(full, 1);
expect(safe, 0);
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
});
testWidgets('plain Ctrl+M still cycles the safe trio only', (tester) async {
var safe = 0, full = 0;
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => safe++, onCycleModeFull: () => full++)));
await tester.tap(find.byType(EditableText));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(safe, 1);
expect(full, 0);
});
});
group('ClaudeComposer external focus node (T-227)', () {
@@ -90,6 +90,20 @@ void main() {
});
});
group('nextPermissionMode (T-510)', () {
test('cycles the full list including bypass and wraps', () {
expect(nextPermissionMode('default'), 'acceptEdits');
expect(nextPermissionMode('acceptEdits'), 'plan');
expect(nextPermissionMode('plan'), 'bypassPermissions');
expect(nextPermissionMode('bypassPermissions'), 'default');
});
test('unknown restarts at default', () {
expect(nextPermissionMode('whatever'), 'default');
expect(kFullPermissionCycle, contains('bypassPermissions'));
});
});
group('statusSegmentsAroundMode (T-226)', () {
test('splits model (leading) from ctx/cost/rate (trailing), mode excluded', () {
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000, cost: 0.05);
@@ -1,10 +1,11 @@
/// Tests for the composer permission-mode control (T-275): opens a menu of the
/// safe trio + a disabled bypass row, selecting sets the mode, and it coexists
/// with the composer's Stop row while busy.
/// safe trio + a shift-click-gated bypass row (T-510), selecting sets the
/// mode, and it coexists with the composer's Stop row while busy.
library;
import 'package:clide/builtin/claude/src/claude_composer.dart';
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -48,7 +49,7 @@ void main() {
expect(find.text('accept-edits'), findsNothing, reason: 'menu closes on select');
});
testWidgets('the bypass row is disabled — selecting it does nothing', (tester) async {
testWidgets('a plain click on the bypass row does nothing and keeps the menu open', (tester) async {
var picked = '';
await pump(tester, 'default', (m) => picked = m);
await tester.pump();
@@ -56,7 +57,24 @@ void main() {
await tester.pump();
await tester.tap(find.text('bypass'));
await tester.pump();
expect(picked, '', reason: 'bypass stays behind the cockpit guard (T-181)');
expect(picked, '', reason: 'bypass is never a plain click away (T-510)');
expect(find.text('bypass'), findsOneWidget, reason: 'menu stays open for a retry with shift');
});
testWidgets('shift-click on the bypass row selects it and closes the menu (T-510)', (tester) async {
var picked = '';
await pump(tester, 'default', (m) => picked = m);
await tester.pump();
await tester.tap(find.byType(PermissionModeControl));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.tap(find.text('bypass'));
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(picked, 'bypassPermissions');
expect(find.text('bypass'), findsNothing, reason: 'menu closes after the shift-select');
});
testWidgets('per-mode helpers map labels/colours/icons', (tester) async {
@@ -53,6 +53,19 @@ void main() {
expect(spawnedArgs.single, isNot(contains('--effort')));
});
test('the workspace PATH preset reaches the spawned session env (D-106)', () async {
final envs = <Map<String, String>?>[];
final preset = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
envs.add(env);
return _FakeProc();
},
pathPresetFor: (cwd) => cwd == '/repo' ? const ['/opt/go/bin'] : const [],
);
await preset.spawn(SpawnSpec(id: 'p1', role: 'primary', sessionId: 'p1-uuid', cwd: '/repo'));
expect(envs.single?['PATH'], startsWith('/opt/go/bin:'), reason: 'preset dirs lead the delta PATH');
});
test('a fresh session gets the skills nudge; resume + fork do not (T-490)', () async {
String appendPrompt(List<String> args) {
final i = args.indexOf('--append-system-prompt');
@@ -0,0 +1,215 @@
import 'package:clide/builtin/graph/src/graph_controller.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/graph/graph_filter.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/fake_ipc.dart';
void main() {
group('GraphController', () {
late DaemonBus bus;
late FakeDaemonClient ipc;
setUp(() {
bus = DaemonBus();
ipc = FakeDaemonClient(log: Logger(), events: bus);
});
tearDown(() => bus.dispose());
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
IpcResponse err(String msg) => IpcResponse.err(
id: '1',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
);
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (per path):
/// outlinks from [vault], tags from [tags].
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
ipc.stub(
'pql.files',
(_) async => ok({
'files': [
for (final p in vault.keys) {'path': p},
],
}),
);
ipc.stub('pql.meta', (args) async {
final path = args['path'] as String?;
return ok({
'outlinks': [
for (final t in vault[path] ?? const []) {'target': t},
],
'tags': tags[path] ?? const <String>[],
});
});
}
test('assembles a graph from files + their meta outlinks', () async {
stubVault({
'a.md': ['b.md'],
'b.md': [],
});
final c = GraphController(ipc: ipc, events: bus);
var notified = 0;
c.addListener(() => notified++);
await c.load();
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
expect(c.loading, isFalse);
expect(c.error, isNull);
expect(notified, greaterThan(0));
});
test('strips #heading fragments so a heading link still connects the notes', () async {
stubVault({
'a.md': ['b.md#a-heading'],
'b.md': [],
});
final c = GraphController(ipc: ipc, events: bus);
await c.load();
expect(c.graph.edgePairs, [('a.md', 'b.md')]); // fragment stripped → real edge
});
test('collects tags into a sorted availableTags union', () async {
stubVault(
{'a.md': const [], 'b.md': const []},
tags: {
'a.md': ['project', 'note'],
'b.md': ['note'],
},
);
final c = GraphController(ipc: ipc, events: bus);
await c.load();
expect(c.availableTags, ['note', 'project']);
});
test('a pql.files failure clears the graph and surfaces the error', () async {
ipc.stub('pql.files', (_) async => err('index locked'));
final c = GraphController(ipc: ipc, events: bus);
await c.load();
expect(c.graph.isEmpty, isTrue);
expect(c.error, contains('index locked'));
expect(c.loading, isFalse);
});
test('a per-file meta failure drops that file\'s edges, keeps the node', () async {
ipc.stub(
'pql.files',
(_) async => ok({
'files': [
{'path': 'a.md'},
{'path': 'b.md'},
],
}),
);
ipc.stub('pql.meta', (args) async {
if (args['path'] == 'a.md') return err('boom');
return ok({'outlinks': const [], 'tags': const []});
});
final c = GraphController(ipc: ipc, events: bus);
await c.load();
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
expect(c.graph.edgePairs, isEmpty);
expect(c.error, isNull);
});
test('setGlob reloads with the new glob; the same glob is a no-op', () async {
final globs = <String>[];
ipc.stub('pql.files', (args) async {
globs.add(args['glob'] as String);
return ok({'files': const []});
});
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
final c = GraphController(ipc: ipc, events: bus);
await c.load();
c.setGlob('notes/**');
await Future<void>.delayed(const Duration(milliseconds: 5));
c.setGlob('notes/**'); // no-op
await Future<void>.delayed(const Duration(milliseconds: 5));
expect(globs, ['**/*.md', 'notes/**']);
expect(c.glob, 'notes/**');
});
test('setFilter narrows visibleGraph without reloading', () async {
var files = 0;
ipc.stub('pql.files', (_) async {
files++;
return ok({
'files': [
{'path': 'a.md'},
{'path': 'b.md'},
],
});
});
ipc.stub(
'pql.meta',
(args) async => ok({
'outlinks': const [],
'tags': args['path'] == 'a.md' ? ['project'] : const <String>[],
}),
);
final c = GraphController(ipc: ipc, events: bus);
await c.load();
expect(files, 1);
expect(c.visibleGraph.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
c.setFilter(const GraphFilter(includeTags: {'project'}));
expect(c.visibleGraph.nodes.map((n) => n.id), ['a.md']);
expect(files, 1); // client-side — no reload
});
test('a depth filter re-centres on the active editor file', () async {
stubVault({
'a.md': ['b.md'],
'b.md': ['c.md'],
'c.md': [],
});
final c = GraphController(ipc: ipc, events: bus);
await c.load();
c.setFilter(const GraphFilter(depth: 1));
expect(c.visibleGraph.isEmpty, isTrue); // no active file yet
bus.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.active-changed', data: const {'path': 'b.md'}, ts: DateTime.now()));
await Future<void>.delayed(const Duration(milliseconds: 5));
expect(c.activePath, 'b.md');
expect(c.visibleGraph.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md', 'c.md']));
});
test('a files event triggers a debounced reload; non-files events do not', () async {
var files = 0;
ipc.stub('pql.files', (_) async {
files++;
return ok({'files': const []});
});
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: Duration.zero);
expect(files, 0);
bus.emit(DaemonEvent(subsystem: 'git', kind: 'git.status', data: const {}, ts: DateTime.now()));
await Future<void>.delayed(const Duration(milliseconds: 5));
expect(files, 0); // non-files event ignored
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: const {'path': 'x.md'}, ts: DateTime.now()));
await Future<void>.delayed(const Duration(milliseconds: 5));
expect(files, 1); // reload fired
c.dispose();
});
test('a burst of files events coalesces into a single reload', () async {
var files = 0;
ipc.stub('pql.files', (_) async {
files++;
return ok({'files': const []});
});
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: const Duration(milliseconds: 20));
for (var i = 0; i < 5; i++) {
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': '$i.md'}, ts: DateTime.now()));
}
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(files, 1);
c.dispose();
});
});
}
@@ -0,0 +1,63 @@
/// GraphExtension (T-323) contributes the vault-graph tab into the context
/// panel — but only once its pql dependency is active (it reads link data
/// through pql).
library;
import 'package:clide/builtin/graph/graph.dart';
import 'package:clide/builtin/graph/src/graph_panel.dart';
import 'package:clide/builtin/pql/pql.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
test('declares one context-panel tab with the graph.view identity', () {
final tabs = GraphExtension().contributions.whereType<TabContribution>().toList();
expect(tabs, hasLength(1));
final t = tabs.single;
expect(t.id, 'graph.view');
expect(t.slot, Slots.contextPanel);
expect(t.title, 'Graph');
expect(t.titleKey, 'tab.graph.title');
expect(t.i18nNamespace, 'builtin.graph');
});
testWidgets('the tab builds a GraphPanel', (tester) async {
final tab = GraphExtension().contributions.whereType<TabContribution>().single;
late Widget built;
await tester.pumpWidget(
anchoredHarness(
f,
Builder(
builder: (ctx) {
built = tab.build(ctx); // capture without mounting — no load/ipc needed
return const SizedBox();
},
),
),
);
expect(built, isA<GraphPanel>());
});
test('registers into the context panel once activated (after its pql dep)', () async {
f.services.extensions.register(PqlExtension());
f.services.extensions.register(GraphExtension());
await f.services.extensions.activate('builtin.pql');
await f.services.extensions.activate('builtin.graph');
expect(f.services.panels.tabsFor(Slots.contextPanel).any((t) => t.id == 'graph.view'), isTrue);
});
test('is skipped when its pql dependency is not active', () async {
f.services.extensions.register(GraphExtension());
await f.services.extensions.activate('builtin.graph'); // dep missing → skipped
expect(f.services.panels.tabsFor(Slots.contextPanel).any((t) => t.id == 'graph.view'), isFalse);
});
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:ui' as ui;
import 'package:clide/builtin/graph/src/graph_painter.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/src/graph/force_layout.dart';
import 'package:clide/src/graph/vault_graph.dart';
import 'package:clide/widgets/src/clide_settings.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<SurfaceTokens> tokensFrom(WidgetTester tester) async {
late SurfaceTokens tokens;
await tester.pumpWidget(
anchoredHarness(
f,
Builder(
builder: (ctx) {
tokens = ClideSettings.theme.of(ctx).surface;
return const SizedBox();
},
),
),
);
return tokens;
}
Future<bool> hasInk(GraphPainter painter, [double s = 200]) async {
final rec = ui.PictureRecorder();
painter.paint(ui.Canvas(rec, Rect.fromLTWH(0, 0, s, s)), Size(s, s));
final img = await rec.endRecording().toImage(s.round(), s.round());
final data = (await img.toByteData())!;
for (var i = 3; i < data.lengthInBytes; i += 4) {
if (data.getUint8(i) != 0) return true;
}
return false;
}
VaultGraph twoNodes() => VaultGraph.fromOutlinks({
'a.md': const ['b.md'],
'b.md': const [],
});
testWidgets('paints nodes, edges, and labels', (tester) async {
final tokens = await tokensFrom(tester);
final g = twoNodes();
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
// toImage/toByteData is real engine async — run it off the fake test clock.
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens))), isTrue);
});
testWidgets('dims nodes outside the hover neighbourhood but still draws ink', (tester) async {
final tokens = await tokensFrom(tester);
final g = twoNodes();
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
// Highlight only a.md — b.md and the edge dim.
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens, highlight: g.neighborhood('a.md')))), isTrue);
});
testWidgets('an empty graph paints nothing (no throw)', (tester) async {
final tokens = await tokensFrom(tester);
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: const VaultGraph([], []), positions: const {}, tokens: tokens))), isFalse);
});
testWidgets('paints under a user zoom + pan', (tester) async {
final tokens = await tokensFrom(tester);
final g = twoNodes();
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens, zoom: 2, pan: const Offset(15, -10)))), isTrue);
});
testWidgets('repaints when zoom or pan changes, not when identical', (tester) async {
final tokens = await tokensFrom(tester);
final g = twoNodes();
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
GraphPainter p({double zoom = 1, Offset pan = Offset.zero}) => GraphPainter(graph: g, positions: pos, tokens: tokens, zoom: zoom, pan: pan);
expect(p().shouldRepaint(p()), isFalse);
expect(p(zoom: 2).shouldRepaint(p()), isTrue);
expect(p(pan: const Offset(1, 0)).shouldRepaint(p()), isTrue);
});
group('hitTestNode', () {
test('finds the node under the point, null when far or empty', () {
final g = VaultGraph.fromOutlinks({'a.md': const []});
// A single node lays out at the layout centre (400,300); an 800×600 canvas
// over an 800×600 layout is scale 1, no offset → the node sits at (400,300).
final pos = ForceLayout.compute(['a.md'], const []);
expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600)), 'a.md');
expect(hitTestNode(g, pos, const Offset(20, 20), const Size(800, 600)), isNull);
expect(hitTestNode(g, const {}, Offset.zero, const Size(800, 600)), isNull);
});
test('pan shifts the hit location; zoom scales about the centre', () {
final g = VaultGraph.fromOutlinks({'a.md': const []});
final pos = ForceLayout.compute(['a.md'], const []); // single node at (400,300)
// A pan moves the node by the same pixels — hit follows it, misses the old spot.
expect(hitTestNode(g, pos, const Offset(500, 300), const Size(800, 600), pan: const Offset(100, 0)), 'a.md');
expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600), pan: const Offset(100, 0)), isNull);
// Zoom scales about the canvas centre, so a centre node stays under it.
expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600), zoom: 3), 'a.md');
});
});
group('GraphViewport.fit', () {
test('a centre point is pan-translated and zoom-invariant', () {
const canvas = Size(800, 600), layout = Size(800, 600);
final centre = (x: 400.0, y: 300.0);
expect(GraphViewport.fit(canvas, layout).toPixel(centre), const Offset(400, 300));
expect(GraphViewport.fit(canvas, layout, zoom: 4).toPixel(centre), const Offset(400, 300));
expect(GraphViewport.fit(canvas, layout, pan: const Offset(10, 20)).toPixel(centre), const Offset(410, 320));
});
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'dart:async';
import 'package:clide/builtin/graph/src/graph_panel.dart';
import 'package:clide/builtin/graph/src/graph_view.dart';
import 'package:clide/clide.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (outlinks + tags).
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
f.ipc.stub(
'pql.files',
(_) async => ok({
'files': [
for (final p in vault.keys) {'path': p},
],
}),
);
f.ipc.stub('pql.meta', (args) async {
final path = args['path'] as String?;
return ok({
'outlinks': [
for (final t in vault[path] ?? const []) {'target': t},
],
'tags': tags[path] ?? const <String>[],
});
});
}
Widget panel([Size size = const Size(400, 400)]) => anchoredHarness(f, SizedBox(width: size.width, height: size.height, child: const GraphPanel()));
testWidgets('shows a spinner (no filter bar) while the first load is in flight', (tester) async {
f.ipc.stub('pql.files', (_) => Completer<IpcResponse>().future);
await tester.pumpWidget(panel());
await tester.pump();
expect(find.byType(ClideSpinner), findsOneWidget);
expect(find.byType(ClideFilterBox), findsNothing);
expect(find.byType(GraphView), findsNothing);
});
testWidgets('renders the graph and the filter bar once loaded', (tester) async {
stubVault({
'a.md': ['b.md'],
'b.md': [],
});
await tester.pumpWidget(panel());
await pumpAsync(tester);
expect(find.byType(GraphView), findsOneWidget);
expect(find.byType(ClideFilterBox), findsOneWidget);
});
testWidgets('shows an empty message for a vault with no notes', (tester) async {
stubVault(const {});
await tester.pumpWidget(panel());
await pumpAsync(tester);
expect(find.text('No linked notes in this vault.'), findsOneWidget);
expect(find.byType(GraphView), findsNothing);
});
testWidgets('surfaces a load error', (tester) async {
f.ipc.stub(
'pql.files',
(_) async => IpcResponse.err(
id: '1',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'index locked'),
),
);
await tester.pumpWidget(panel());
await pumpAsync(tester);
expect(find.text('index locked'), findsOneWidget);
expect(find.byType(GraphView), findsNothing);
});
testWidgets('clicking a node opens it in the editor', (tester) async {
stubVault({'only.md': const []});
String? opened;
f.ipc.stub('editor.open', (args) async {
opened = args['path'] as String?;
return ok(const {});
});
await tester.pumpWidget(panel());
await pumpAsync(tester);
await tester.tap(find.byType(GraphView));
await pumpAsync(tester);
expect(opened, 'only.md');
});
testWidgets('a depth pill with no active note shows the local-graph hint', (tester) async {
stubVault({
'a.md': ['b.md'],
'b.md': [],
});
await tester.pumpWidget(panel());
await pumpAsync(tester);
await tester.tap(find.text('1')); // depth-1 from the (absent) active note
await pumpAsync(tester);
expect(find.text('Open a note to see its local graph.'), findsOneWidget);
expect(find.byType(GraphView), findsNothing);
});
testWidgets('a tag pill cycles include → exclude and filters the graph', (tester) async {
stubVault(
{'only.md': const []},
tags: {
'only.md': ['x'],
},
);
await tester.pumpWidget(panel());
await pumpAsync(tester);
expect(find.text('x'), findsOneWidget); // neutral tag pill
await tester.tap(find.text('x')); // → include; the note carries x, so it stays
await pumpAsync(tester);
expect(find.byType(GraphView), findsOneWidget);
await tester.tap(find.text('+x')); // → exclude; the note carries x, so it drops
await pumpAsync(tester);
expect(find.text('No notes match the filter.'), findsOneWidget);
expect(find.byType(GraphView), findsNothing);
});
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:clide/builtin/graph/src/graph_view.dart';
import 'package:clide/src/graph/vault_graph.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('clicking a node opens it', (tester) async {
String? opened;
final g = VaultGraph.fromOutlinks({'only.md': const []});
await tester.pumpWidget(
anchoredHarness(
f,
SizedBox(
width: 300,
height: 300,
child: GraphView(graph: g, layoutSize: const Size(300, 300), onOpen: (id) => opened = id),
),
),
);
await tester.pump();
// A single node lays out at the centre → the view's centre.
await tester.tap(find.byType(GraphView));
expect(opened, 'only.md');
});
testWidgets('renders an empty graph without error', (tester) async {
await tester.pumpWidget(anchoredHarness(f, const SizedBox(width: 200, height: 200, child: GraphView(graph: VaultGraph([], [])))));
await tester.pump();
expect(find.byType(GraphView), findsOneWidget);
await tester.tap(find.byType(GraphView)); // no node, no onOpen — must not throw
});
testWidgets('dragging pans the graph; a reverse pan restores it', (tester) async {
String? opened;
final g = VaultGraph.fromOutlinks({'only.md': const []});
await tester.pumpWidget(
anchoredHarness(
f,
SizedBox(
width: 400,
height: 400,
child: GraphView(graph: g, onOpen: (id) => opened = id),
),
),
);
await tester.pump();
final centre = tester.getCenter(find.byType(GraphView));
// Pan right — the node leaves the centre, so a centre tap misses. (An equal,
// opposite drag cancels the same gesture slop, so the net returns to zero —
// robust to the exact slop the arena consumes.)
await tester.drag(find.byType(GraphView), const Offset(120, 0));
await tester.pump();
await tester.tapAt(centre);
expect(opened, isNull);
await tester.drag(find.byType(GraphView), const Offset(-120, 0));
await tester.pump();
await tester.tapAt(centre);
expect(opened, 'only.md');
});
testWidgets('a scroll signal zooms without throwing', (tester) async {
final g = VaultGraph.fromOutlinks({
'a.md': const ['b.md'],
'b.md': const [],
});
await tester.pumpWidget(anchoredHarness(f, SizedBox(width: 400, height: 400, child: GraphView(graph: g))));
await tester.pump();
final centre = tester.getCenter(find.byType(GraphView));
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(centre));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120))); // zoom in
await tester.pump();
expect(find.byType(GraphView), findsOneWidget);
});
}
+211
View File
@@ -0,0 +1,211 @@
/// Tests for the backlinks panel — [BacklinksController] (active-file
/// tracking, pql fetches, event-driven refresh) and [BacklinksView]
/// (empty/loading/error states, link groups, row navigation). Previously
/// untested; brought under test when the T-511 coverage sweep exposed the
/// gap.
library;
import 'package:clide/builtin/pql/src/backlinks_controller.dart';
import 'package:clide/builtin/pql/src/backlinks_view.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
IpcResponse _err(String m) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: m),
);
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
void stubLinks({Object? backlinks = const [], Object? outlinks = const [], bool fail = false}) {
f.ipc.stub('pql.backlinks', (_) async => fail ? _err('vault gone') : _ok({'links': backlinks}));
f.ipc.stub('pql.outlinks', (_) async => fail ? _err('vault gone') : _ok({'links': outlinks}));
}
DaemonEvent activeChanged(String path) => DaemonEvent(subsystem: 'editor', kind: 'editor.active-changed', data: {'path': path}, ts: DateTime.now().toUtc());
group('BacklinksController', () {
late BacklinksController c;
setUp(() => c = BacklinksController(ipc: f.ipc, events: f.services.events));
tearDown(() => c.dispose());
test('loadForPath populates both link lists and toggles loading', () async {
stubLinks(
backlinks: [
{'source': 'notes/in.md'},
],
outlinks: [
{'target': 'notes/out.md', 'alias': 'Out'},
],
);
final loadingSeen = <bool>[];
c.addListener(() => loadingSeen.add(c.loading));
await c.loadForPath('notes/a.md');
expect(c.activePath, 'notes/a.md');
expect(loadingSeen, [true, false]);
expect(c.backlinks.single['source'], 'notes/in.md');
expect(c.outlinks.single['alias'], 'Out');
expect(c.error, isNull);
});
test('both fetches failing surfaces the error; lists stay empty', () async {
stubLinks(fail: true);
await c.loadForPath('a.md');
expect(c.error, 'vault gone');
expect(c.backlinks, isEmpty);
expect(c.outlinks, isEmpty);
});
test('one side failing degrades to an empty list without an error', () async {
f.ipc.stub('pql.backlinks', (_) async => _err('half down'));
f.ipc.stub(
'pql.outlinks',
(_) async => _ok({
'links': [
{'target': 'b.md'},
],
}),
);
await c.loadForPath('a.md');
expect(c.error, isNull);
expect(c.backlinks, isEmpty);
expect(c.outlinks, hasLength(1));
});
test('a malformed links payload is tolerated as empty', () async {
f.ipc.stub('pql.backlinks', (_) async => _ok(const {'links': 'nonsense'}));
f.ipc.stub('pql.outlinks', (_) async => _ok(const {}));
await c.loadForPath('a.md');
expect(c.backlinks, isEmpty);
expect(c.outlinks, isEmpty);
});
test('editor.active-changed refreshes; same path and foreign events do not', () async {
var calls = 0;
f.ipc.stub('pql.backlinks', (_) async {
calls++;
return _ok(const {'links': []});
});
f.ipc.stub('pql.outlinks', (_) async => _ok(const {'links': []}));
f.services.events.emit(activeChanged('a.md'));
await Future<void>.delayed(Duration.zero);
expect(calls, 1);
f.services.events.emit(activeChanged('a.md')); // same path — no refetch
f.services.events.emit(DaemonEvent(subsystem: 'git', kind: 'changed', data: const {}, ts: DateTime.now().toUtc()));
f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.saved', data: const {'path': 'b.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
expect(calls, 1);
f.services.events.emit(activeChanged('b.md'));
await Future<void>.delayed(Duration.zero);
expect(calls, 2);
});
test('dispose stops listening to the bus', () async {
var calls = 0;
f.ipc.stub('pql.backlinks', (_) async {
calls++;
return _ok(const {'links': []});
});
f.ipc.stub('pql.outlinks', (_) async => _ok(const {'links': []}));
c.dispose();
f.services.events.emit(activeChanged('a.md'));
await Future<void>.delayed(Duration.zero);
expect(calls, 0);
c = BacklinksController(ipc: f.ipc, events: f.services.events); // tearDown disposes a live one
});
});
group('BacklinksView', () {
Future<void> pump(WidgetTester tester) => tester.pumpWidget(
harness(
f,
const Align(
alignment: Alignment.center,
child: SizedBox(width: 320, height: 400, child: BacklinksView()),
),
),
);
testWidgets('no active file → empty-state prompt', (tester) async {
await pump(tester);
await tester.pump();
expect(find.textContaining('Open a file'), findsOneWidget);
});
testWidgets('an active-file change renders the file name, groups, and rows', (tester) async {
stubLinks(
backlinks: [
{'source': 'notes/in.md'},
],
outlinks: [
{'target': 'https://example.com', 'alias': 'Site'},
],
);
await pump(tester);
f.services.events.emit(activeChanged('notes/active.md'));
await tester.pump();
await tester.pump();
expect(find.text('active.md'), findsOneWidget, reason: 'header shows the basename');
expect(find.text('Backlinks (1)'), findsOneWidget);
expect(find.text('Outlinks (1)'), findsOneWidget);
expect(find.text('notes/in.md'), findsOneWidget);
expect(find.text('Site'), findsOneWidget, reason: 'alias wins over the raw target');
expect(find.text('None'), findsNothing);
});
testWidgets('empty groups say None; a failed fetch surfaces the error', (tester) async {
stubLinks(fail: true);
await pump(tester);
f.services.events.emit(activeChanged('a.md'));
await tester.pump();
await tester.pump();
expect(find.text('vault gone'), findsOneWidget);
expect(find.text('None'), findsNWidgets(2));
});
testWidgets('tapping a vault link opens it in the editor; an http link does not', (tester) async {
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path']! as String);
return _ok(const {});
});
stubLinks(
backlinks: [
{'source': 'notes/in.md'},
],
outlinks: [
{'target': 'https://example.com'},
],
);
await pump(tester);
f.services.events.emit(activeChanged('a.md'));
await tester.pump();
await tester.pump();
await tester.tap(find.text('notes/in.md'));
await tester.pump();
expect(opened, ['notes/in.md']);
await tester.tap(find.text('https://example.com'));
await tester.pump();
expect(opened, ['notes/in.md'], reason: 'http links never route to the editor');
});
});
}
@@ -0,0 +1,200 @@
/// D-106/T-511: the per-workspace PATH preset settings control. Covers the
/// no-workspace and empty states, add/remove/reorder against the real
/// settings store, worktree key sharing, live updates on a CLI-side write,
/// and the capture-from-login-shell suggestion flow.
///
/// Settings writes are real file I/O, so seeding goes through
/// [WidgetTester.runAsync] — awaiting it inside the fake-async body would hang.
library;
import 'dart:io';
import 'package:clide/builtin/tools_settings/src/path_preset_control.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel;
import 'package:clide/src/env/path_preset.dart';
import 'package:clide/src/env/shell_env.dart' show debugResetLoginShellPath, debugSetLoginShellPath;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
List<String> dirs(String root) => presetDirsFrom((k) => f.services.settings.get<Object>(k), root);
Future<void> pump(WidgetTester tester) => tester.pumpWidget(
harness(
f,
const Align(
alignment: Alignment.center,
child: SizedBox(width: 460, child: PathPresetControl()),
),
),
);
testWidgets('no workspace open → prompts to open one', (tester) async {
await pump(tester);
await tester.pump();
expect(find.textContaining('Open a workspace'), findsOneWidget);
});
testWidgets('empty preset shows the hint + the add row', (tester) async {
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
expect(find.textContaining('No preset entries'), findsOneWidget);
expect(find.text('Add entry'), findsOneWidget);
});
testWidgets('typing a dir + Add persists it, publishes on envPathChannel, and renders the row', (tester) async {
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
final published = <Map<String, Object?>>[];
final sub = f.services.messages.subscribe(channel: envPathChannel).listen((m) => published.add(m.data));
addTearDown(sub.cancel);
await pump(tester);
await tester.pump();
await tester.enterText(find.byType(EditableText), '/opt/go/bin');
await tester.tap(find.text('Add entry'));
await tester.pump();
expect(dirs(f.tempDir.path), ['/opt/go/bin']);
expect(find.text('/opt/go/bin'), findsOneWidget);
expect(find.text('missing'), findsOneWidget, reason: 'the dir does not exist → warning tag');
expect(published.single['action'], 'add');
expect(published.single['dirs'], ['/opt/go/bin']);
});
testWidgets('a relative entry is rejected with a visible reason and nothing is written', (tester) async {
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.enterText(find.byType(EditableText), 'go/bin');
await tester.tap(find.text('Add entry'));
await tester.pump();
expect(find.textContaining('absolute path'), findsOneWidget);
expect(dirs(f.tempDir.path), isEmpty);
});
testWidgets('an entry containing a PATH separator is rejected (CWD-token guard)', (tester) async {
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.enterText(find.byType(EditableText), '/opt/go/bin:');
await tester.tap(find.text('Add entry'));
await tester.pump();
expect(find.textContaining('absolute path'), findsOneWidget);
expect(dirs(f.tempDir.path), isEmpty);
});
testWidgets('~/ expands against HOME and Enter submits (parity with the CLI verb)', (tester) async {
final home = Platform.environment['HOME'];
if (home == null || home.isEmpty) return; // no HOME in this environment — the guard path is CLI-tested
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.enterText(find.byType(EditableText), '~/go/bin/');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(dirs(f.tempDir.path), ['$home/go/bin']);
});
testWidgets('an existing dir renders without the missing tag', (tester) async {
await tester.runAsync(() async {
await f.services.settings.setProjectDir(f.tempDir);
await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), [f.tempDir.path]);
});
await pump(tester);
await tester.pump();
expect(find.text(f.tempDir.path), findsOneWidget);
expect(find.text('missing'), findsNothing);
});
testWidgets('remove and reorder rewrite the stored order', (tester) async {
await tester.runAsync(() async {
await f.services.settings.setProjectDir(f.tempDir);
await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/a', '/b', '/c']);
});
await pump(tester);
await tester.pump();
await tester.tap(find.bySemanticsLabel('Move down: /a'));
await tester.pump();
expect(dirs(f.tempDir.path), ['/b', '/a', '/c']);
await tester.tap(find.bySemanticsLabel('Move up: /c'));
await tester.pump();
expect(dirs(f.tempDir.path), ['/b', '/c', '/a']);
await tester.tap(find.bySemanticsLabel('Remove: /b'));
await tester.pump();
expect(dirs(f.tempDir.path), ['/c', '/a']);
});
testWidgets('live-updates when the preset changes from outside (CLI-side write)', (tester) async {
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
expect(find.textContaining('No preset entries'), findsOneWidget);
await tester.runAsync(() => f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/from/cli']));
await tester.pump();
expect(find.text('/from/cli'), findsOneWidget);
});
testWidgets('opened from an in-repo worktree: says so and edits the main repo key (D-106)', (tester) async {
late Directory repo;
late Directory wt;
await tester.runAsync(() async {
repo = Directory('${f.tempDir.path}/repo')..createSync();
Directory('${repo.path}/.git/worktrees/fix').createSync(recursive: true);
wt = Directory('${repo.path}/.worktrees/fix')..createSync(recursive: true);
File('${wt.path}/.git').writeAsStringSync('gitdir: ${repo.path}/.git/worktrees/fix\n');
await f.services.settings.setProjectDir(wt);
});
await pump(tester);
await tester.pump();
expect(find.textContaining('Worktree'), findsOneWidget);
expect(find.text(repo.path), findsOneWidget, reason: 'names the shared main repo root');
await tester.enterText(find.byType(EditableText), '/opt/go/bin');
await tester.tap(find.text('Add entry'));
await tester.pump();
expect(dirs(repo.path), ['/opt/go/bin'], reason: 'worktree writes land on the main repo key');
expect(dirs(wt.path), ['/opt/go/bin'], reason: 'reading via the worktree resolves the same key');
});
testWidgets('capture suggests login-shell dirs and a tap adopts one', (tester) async {
debugSetLoginShellPath('/cap-a:${Platform.environment['PATH'] ?? ''}');
addTearDown(debugResetLoginShellPath);
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.tap(find.text('Suggest from login shell'));
await tester.pump();
expect(find.text('/cap-a'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('Add suggested entry: /cap-a'));
await tester.pump();
expect(dirs(f.tempDir.path), ['/cap-a']);
expect(find.bySemanticsLabel('Add suggested entry: /cap-a'), findsNothing, reason: 'adopted suggestion leaves the list');
});
testWidgets('capture with no login-shell probe reports nothing to suggest', (tester) async {
debugResetLoginShellPath();
addTearDown(debugResetLoginShellPath);
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.tap(find.text('Suggest from login shell'));
await tester.pump();
expect(find.textContaining('Nothing to suggest'), findsOneWidget);
});
}
+230
View File
@@ -0,0 +1,230 @@
/// Tests for `env path …` (D-106, T-511; D-6 CLI parity). Verifies each verb's
/// store effect, the published mutation payloads, the capture diff, and honest
/// userErrors — against a fake PathPresetStore so the handler stays
/// Flutter-free (runs under `dart test`).
library;
import 'package:clide/clide.dart';
import 'package:clide/src/daemon/env_path_commands.dart';
import 'package:test/test.dart';
class _FakeStore implements PathPresetStore {
final Map<String, List<String>> byCwd = {};
@override
List<String> dirsFor(String cwd) => List.of(byCwd[cwd] ?? const []);
@override
Future<void> setFor(String cwd, List<String> dirs) async {
if (dirs.isEmpty) {
byCwd.remove(cwd);
} else {
byCwd[cwd] = List.of(dirs);
}
}
}
void main() {
late _FakeStore store;
late List<({String publisher, String channel, Map<String, Object?> data})> published;
late DaemonDispatcher d;
late Set<String> existingDirs;
String? loginPath;
var processPath = '/usr/bin:/bin';
void wire({String? cwd = '/repo', bool withStore = true, String? home = '/home/u'}) {
store = _FakeStore();
published = [];
existingDirs = {};
loginPath = null;
processPath = '/usr/bin:/bin';
d = DaemonDispatcher();
registerEnvPathCommands(
d,
() => withStore ? store : null,
publisher: () =>
(p, c, data) => published.add((publisher: p, channel: c, data: data)),
workspaceCwd: () => cwd,
home: () => home,
dirExists: (dir) => existingDirs.contains(dir),
loginPath: () => loginPath,
processPath: () => processPath,
);
}
Future<IpcResponse> run(List<String> positional) => d.dispatch(IpcRequest(id: '1', cmd: 'env.path', args: {'positional': positional}));
test('registered on the dispatcher → shows in capabilities', () async {
wire();
final caps = await d.dispatch(IpcRequest(id: 'c', cmd: 'capabilities', args: const {}));
expect((caps.data['commands'] as Map).containsKey('env.path'), isTrue);
});
group('set', () {
test('replaces the preset, de-duplicated, and publishes', () async {
wire();
existingDirs.add('/opt/go/bin');
final r = await run(['set', '/opt/go/bin', '/x', '/opt/go/bin']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['dirs'], ['/opt/go/bin', '/x']);
expect(r.data['missing'], ['/x'], reason: 'nonexistent dirs warn, never error');
expect(store.byCwd['/repo'], ['/opt/go/bin', '/x']);
expect(published.single.channel, envPathChannel);
expect(published.single.data['action'], 'set');
expect(published.single.data['dirs'], ['/opt/go/bin', '/x']);
});
test('expands ~/ against HOME and strips trailing slashes', () async {
wire();
final r = await run(['set', '~/go/bin/', '~']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['dirs'], ['/home/u/go/bin', '/home/u']);
});
test('relative paths error; empty set points at clear', () async {
wire();
final rel = await run(['set', 'go/bin']);
expect(rel.ok, isFalse);
expect(rel.error?.message, contains('not an absolute path'));
final empty = await run(['set']);
expect(empty.ok, isFalse);
expect(empty.error?.hint, contains('clear'));
});
test('add and remove apply the same guards as set (relative + separator)', () async {
wire();
for (final action in ['set', 'add', 'remove']) {
final rel = await run([action, 'go/bin']);
expect(rel.ok, isFalse, reason: '$action relative');
expect(rel.error?.message, contains('not an absolute path'));
final sep = await run([action, '/a:']);
expect(sep.ok, isFalse, reason: '$action separator');
expect(sep.error?.message, contains('PATH separator'));
}
expect(store.byCwd, isEmpty, reason: 'nothing was written');
});
test('~ with no HOME errors instead of storing a broken entry', () async {
wire(home: null);
final r = await run(['set', '~/go/bin']);
expect(r.ok, isFalse);
expect(r.error?.message, contains('cannot expand'));
});
test('a leading-dash entry is rejected by the schema (T-104 guard)', () async {
wire();
final r = await d.dispatch(
IpcRequest(
id: '1',
cmd: 'env.path',
args: {
'positional': ['set'],
'flags': {'dirs': '--evil'},
},
),
);
expect(r.ok, isFalse);
});
});
group('add / remove / clear', () {
test('add appends without duplicating; remove drops; clear empties the key', () async {
wire();
await run(['set', '/a']);
published.clear();
var r = await run(['add', '/b', '/a']);
expect(r.data['dirs'], ['/a', '/b']);
r = await run(['remove', '/a']);
expect(r.data['dirs'], ['/b']);
r = await run(['clear']);
expect(r.ok, isTrue);
expect(r.data['dirs'], isEmpty);
expect(store.byCwd.containsKey('/repo'), isFalse, reason: 'empty preset removes the key');
expect(published.map((p) => p.data['action']), ['add', 'remove', 'clear']);
});
test('add and remove require a <dir>; removing an absent entry errors', () async {
wire();
expect((await run(['add'])).ok, isFalse);
expect((await run(['remove'])).ok, isFalse);
final r = await run(['remove', '/ghost']);
expect(r.ok, isFalse);
expect(r.error?.message, contains('no preset entry matches'));
});
});
group('list', () {
test('returns the preset, the missing subset, and the effective PATH preview', () async {
wire();
existingDirs.add('/a');
await run(['set', '/a', '/gone']);
final r = await run(['list']);
expect(r.data['root'], '/repo');
expect(r.data['dirs'], ['/a', '/gone']);
expect(r.data['missing'], ['/gone']);
expect(r.data['effectivePath'], startsWith('/a:/gone:'), reason: 'preset prepends the resolved PATH');
});
});
group('capture', () {
test('suggests login-shell dirs the process PATH lacks, minus the preset', () async {
wire();
loginPath = '/brew/bin:/usr/bin:/opt/go/bin:/bin';
await run(['set', '/brew/bin']);
final r = await run(['capture']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['suggested'], ['/opt/go/bin']);
expect(r.data['loginShellPath'], loginPath);
expect(r.data['processPath'], '/usr/bin:/bin');
});
test('no login-shell probe → empty suggestions with an honest note', () async {
wire();
final r = await run(['capture']);
expect(r.data['suggested'], isEmpty);
expect(r.data['note'], contains('unavailable'));
});
});
test('un-injected production seams (HOME / dir probe / process PATH) work', () async {
store = _FakeStore();
d = DaemonDispatcher();
registerEnvPathCommands(d, () => store, workspaceCwd: () => '/repo');
final set = await d.dispatch(
IpcRequest(
id: '1',
cmd: 'env.path',
args: const {
'positional': ['set', '/definitely-missing-dir'],
},
),
);
expect(set.ok, isTrue, reason: set.error?.message);
expect(set.data['missing'], ['/definitely-missing-dir'], reason: 'real Directory probe ran');
final cap = await d.dispatch(
IpcRequest(
id: '2',
cmd: 'env.path',
args: const {
'positional': ['capture'],
},
),
);
expect(cap.ok, isTrue);
expect(cap.data['processPath'], isA<String>());
});
test('no workspace / no store / unknown action error clearly', () async {
wire(cwd: null);
expect((await run(['list'])).error?.message, contains('no workspace'));
wire(withStore: false);
expect((await run(['list'])).error?.message, contains('unavailable'));
wire();
final r = await run(['frobnicate']);
expect(r.ok, isFalse);
expect(r.error?.hint, contains('list | set | add | remove | clear | capture'));
});
}
+3 -1
View File
@@ -35,7 +35,9 @@ void main() {
final target = '${parent.path}/my-app';
expect(r.path, target);
expect(Directory(target).existsSync(), isTrue);
expect(File('$target/.gitignore').existsSync(), isTrue);
final ignored = File('$target/.gitignore').readAsStringSync();
expect(ignored, contains('.clide/'));
expect(ignored, contains('.worktrees/'), reason: 'in-repo worktree checkouts stay out of git (D-106)');
expect(File('$target/CLAUDE.md').readAsStringSync(), contains('my-app'));
expect(inited, [target]);
});
+11 -1
View File
@@ -59,6 +59,16 @@ void main() {
expect(published.single.data, {'path': 'lib/main.dart'});
});
test('canvas keys on path and publishes a builtin.canvas selection (T-322)', () async {
wire();
final r = await open(['canvas', 'notes/board.canvas']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['opened'], isTrue);
expect(published.single.publisher, 'builtin.canvas');
expect(published.single.channel, 'selection');
expect(published.single.data, {'path': 'notes/board.canvas'});
});
test('named args work too (reader/ref)', () async {
wire();
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'ui.open', args: {'reader': 'tickets', 'ref': 'T-7'}));
@@ -68,7 +78,7 @@ void main() {
test('unknown reader → userError, nothing published', () async {
wire();
final r = await open(['canvas', 'foo']);
final r = await open(['bogus', 'foo']);
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(published, isEmpty);
+1 -1
View File
@@ -61,7 +61,7 @@ void main() {
test('built-in set hides clide-owned dirs', () {
final s = IgnoreSet.builtin();
for (final d in const ['.git', '.pql', '.clide', '.dart_tool', 'build', 'node_modules']) {
for (final d in const ['.git', '.pql', '.clide', '.dart_tool', '.worktrees', 'build', 'node_modules']) {
expect(s.isIgnored(d, isDirectory: true), isTrue, reason: d);
}
expect(s.isIgnored('lib', isDirectory: true), isFalse);
+70
View File
@@ -0,0 +1,70 @@
/// openWorkspaceFile (T-51/T-187) — the single dispatch point for file
/// activations: `.md` → markdown reader, `.canvas` → canvas pane (T-322),
/// everything else → the editor via IPC. Also records recents.
library;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart';
void main() {
late KernelFixture f;
late List<Message> published;
late List<Object?> editorOpens;
setUp(() async {
f = await KernelFixture.create();
published = [];
editorOpens = [];
f.services.messages.subscribe().listen(published.add);
f.ipc.stub('editor.open', (args) async {
editorOpens.add(args['path']);
return IpcResponse.ok(id: '1', data: const {});
});
});
tearDown(() => f.dispose());
Future<void> deliver() => Future<void>.delayed(Duration.zero);
test('.md routes to the markdown reader via the bus', () async {
openWorkspaceFile(f.services, 'docs/plan.md');
await deliver();
expect(published, hasLength(1));
expect(published.single.publisher, 'builtin.markdown');
expect(published.single.channel, 'selection');
expect(published.single.data, {'path': 'docs/plan.md'});
expect(editorOpens, isEmpty);
});
test('.canvas routes to the canvas pane via the bus (T-322)', () async {
openWorkspaceFile(f.services, 'notes/board.canvas');
await deliver();
expect(published, hasLength(1));
expect(published.single.publisher, 'builtin.canvas');
expect(published.single.channel, 'selection');
expect(published.single.data, {'path': 'notes/board.canvas'});
expect(editorOpens, isEmpty);
});
test('extension match is case-insensitive', () async {
openWorkspaceFile(f.services, 'BOARD.CANVAS');
await deliver();
expect(published.single.publisher, 'builtin.canvas');
});
test('everything else opens in the editor over IPC', () async {
openWorkspaceFile(f.services, 'lib/main.dart');
await deliver();
expect(published, isEmpty);
expect(editorOpens, ['lib/main.dart']);
});
test('records the open in recent files; empty path is a no-op', () async {
openWorkspaceFile(f.services, 'notes/board.canvas');
openWorkspaceFile(f.services, '');
await deliver();
expect(f.services.recentFiles.paths, ['notes/board.canvas']);
});
}
+14
View File
@@ -37,6 +37,20 @@ void main() {
loaded.dispose();
});
test('numeric-shaped key segments round-trip unmangled (workspace-hash keys)', () async {
// FNV workspace-hash suffixes (app.env.pathPrepend.<hash>, the account
// bindings) can be number-shaped; an unquoted YAML key would reload as
// an int (leading zero dropped) or a float ('1e…' → Infinity) and
// silently orphan the stored value.
await store.set<List<String>>('app.env.pathPrepend.0123456789012345', const ['/opt/go/bin']);
await store.set<String>('app.claude.account.1e23456789012345', 'work');
final loaded = SettingsStore(appDir: tmp);
await loaded.load();
expect(loaded.get<List<dynamic>>('app.env.pathPrepend.0123456789012345'), ['/opt/go/bin']);
expect(loaded.get<String>('app.claude.account.1e23456789012345'), 'work');
loaded.dispose();
});
test('app.* scope supports bool + int + list', () async {
await store.set<bool>('app.extensions.git.enabled', false);
await store.set<int>('app.layout.width', 240);
+26
View File
@@ -99,6 +99,32 @@ void main() {
expect(pane.kind, PaneKind.claude);
expect(pane.toJson()['kind'], 'claude');
});
test('pathForSpawn hook sets the child PATH per cwd (D-106)', tags: ['pty'], () async {
String? seenCwd;
final preset = PaneRegistry(
events: sink,
pathForSpawn: (cwd) {
seenCwd = cwd;
return '/preset-marker:/usr/bin:/bin';
},
);
addTearDown(preset.shutdown);
final buf = StringBuffer();
final got = Completer<void>();
final sub = sink.stream.listen((e) {
if (e.kind != 'pane.output') return;
buf.write(utf8.decode(base64Decode(e.data['bytes_b64']! as String)));
if (buf.toString().contains('/preset-marker') && !got.isCompleted) got.complete();
});
addTearDown(sub.cancel);
await preset.spawn(kind: PaneKind.terminal, argv: const ['/bin/sh', '-c', r'printf %s "$PATH"; sleep 0.25'], cwd: '/tmp');
await got.future.timeout(ioTimeout, onTimeout: () => fail('child PATH never carried the preset marker within ${ioTimeout.inSeconds}s'));
expect(seenCwd, '/tmp');
});
});
group('RecordingEventSink filters', () {

Some files were not shown because too many files have changed in this diff Show More