91 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 1e8ee2c412 test(coverage): lift os/code-block/deeplink for gate headroom
The three worst-covered files were genuinely untested, not edge cases:
os.dart 27%→~85% (inject the process runner so openURL/reveal don't spawn a
real browser), clide_code_block 39%→~90% (expose the byte→char span mapper
as a top-level fn + render tests), deeplink 29%→~75% (the confirm-opens and
not-activated paths). Buys buffer above the 95% floor so a feature batch
doesn't immediately trip the gate. 95.01% → 95.22%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:01:08 +02:00
jpmschweitzerandClaude Opus 4.8 1d9000988d test(draw): cover the 2.9.0 drawing-card additions to clear the coverage floor
The drawing-card feature batch (icon/image/compare/graph/d2 cards, --stdin,
tool resolution) added widget + wiring code that dipped total coverage under
the 95% floor — surfaced by `make release` (push-check skips coverage). Cover
the gaps: ProblemsController.refresh, every SVG shape-type bbox + the style
vocabulary, _spawnD2 via a real /bin/cat, the icon-show bus path + error
branches, and quad/arc/close marker paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:00:56 +02:00
jpmschweitzer 9abcb0c30c release v2.9.0 2026-06-30 00:36:07 +02:00
jpmschweitzerandClaude Opus 4.8 2150a85b2f fix(cli): make clide draw reachable as an umbrella command (T-318)
draw is single-token (no verb), so `clide draw --file x` mis-parsed as cmd
"draw.--file" — the dispatcher registered draw but the argv parser never
learned it was umbrella (unlike icon/image which use subsystem.verb). Found
driving the live install; the handler unit tests bypassed the argv path so
they missed it. Added draw to _umbrellaCommands + a parse regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 00:28:28 +02:00
jpmschweitzer b0cc60735c chore(plan): split Tier-5 interactive panes (T-322/T-323) into epic T-506 2026-06-30 00:18:11 +02:00
jpmschweitzerandClaude Opus 4.8 bcef3a6fca feat(graph): deterministic force-directed layout solver (T-323)
Fruchterman-Reingold (clide-owned, no layout package): nodes repel, edges
attract, cooled over iterations into a readable layout. Deterministic — a
fixed circular seed, no RNG — so the graph view is stable across rebuilds
and the solver is unit-tested (6 cases). Flutter-free; the interactive graph
pane (render, pan/zoom, hover, filter, pql wiring) builds on this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 00:03:44 +02:00
jpmschweitzerandClaude Opus 4.8 c167a37ea7 feat(draw): graph card template — circular node/edge layout (T-321)
graphTemplateHandler lowers a {nodes,edges} payload to SVG: a deterministic
circular layout of labelled <circle> nodes with <line> edges, self-contained
(own light backdrop + content colors, like a d2 card) and painted by the
shared renderer. Display-only (D-78); decoupled from the interactive graph
pane (T-323) since a static card only needs a layout->SVG. Honest error on
a duplicate id or an edge to an unknown node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 00:00:44 +02:00
jpmschweitzerandClaude Opus 4.8 64410e8047 feat(cli): --stdin piped JSON payloads for structured commands (T-315)
The C client slurps stdin when it sees --stdin, strips the flag, and ships
the payload alongside the argv; the Dart unwrap folds it into the request
as a `stdin` arg (undeclared keys pass the schema untouched). icon.show and
image.show now read that payload as the peer of --file (stdin wins). Bounded
slurp + the envelope's existing size guard keep a huge payload from
corrupting the wire. C client builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:56:33 +02:00
jpmschweitzerandClaude Opus 4.8 1f6d31ac73 feat(draw): wire compare card image loading + register the template (T-319)
The drawing card now loads any <image> hrefs its SVG references into
ui.Images before painting (loadDrawingImages, decoder injectable for
tests) — the renderer and the lightbox both paint through the resolver, so
a compare card shows real images and zooms. main registers the `compare`
template with a workspace path resolver. A drawing with no images is
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:02:21 +02:00
jpmschweitzerandClaude Opus 4.8 a651c9e026 feat(draw): compare template + aspect-fit image painting (T-319)
compareTemplateHandler lowers an `images` array to an SVG of side-by-side
<image> cells, each with the per-object data-label/description caption and
data-lightbox; paths resolve to absolute up front (injected, honest error
on a miss). _paintImage now aspect-fits (contain, centered) so cells of
differing-shape images don't distort. Flutter-free handler + painter pixel
tests. Card image-loading + registration next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:57:47 +02:00
jpmschweitzerandClaude Opus 4.8 281fb2a869 feat(icon): Phosphor glyph card — hero + real-UI-size strip (T-313)
The icon card renders each glyph at a hero size plus a continuous sample
strip (10–48px) so legibility is judged at the sizes the app uses, with the
entry's optional label + description and a per-entry or card-level color.
Wired end to end: icon.show publishes on the `icon` bus, the Claude
extension injects an IconMessage, and conversation_view paints the card
(display-only, D-78). en+nl catalogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:10:53 +02:00
jpmschweitzerandClaude Opus 4.8 9a2b4e9eca feat(icon): icon.show handler + variadic positional schema (T-313)
icon.show resolves Phosphor glyphs by name (injected resolver) or a 0xNNNN
codepoint, reads a --file JSON array of {icon,label,description,color}
entries, validates colors via parseSvgColor (hex or CSS name), and
publishes on the `icon` bus channel. Honest userError on an unknown glyph,
a bad color, or a malformed payload. A trailing stringList positional is
now variadic so `icon show gear folder gauge` collects every token.
Flutter-free, dart-tested. Card widget + wiring next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:04:44 +02:00
jpmschweitzerandClaude Opus 4.8 f0fb5a5134 feat(image): --file metadata payload for annotated image cards (T-316)
`clide image show --file meta.json` reads a {path,label,description,caption}
payload so an image can carry a title and a longer description, not just a
one-line caption. ImageMessage + the image card render the richer metadata;
the bare `image show <path> [--caption]` form is unchanged. Honest userError
on a malformed/missing payload. Text annotation only (option a) — visual
marker overlays stay a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:58:30 +02:00
jpmschweitzerandClaude Opus 4.8 5f8fa6af10 feat(draw): fold the d2 source into a "view source" disclosure (T-494)
The rendered diagram leads; the d2 source carries through the draw bus and
folds into a collapsed ClideCollapserCard beneath the card (D-78 display-
only). en+nl catalogs. Also switches the d2 test to a null-aware element.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:08:33 +02:00
jpmschweitzerandClaude Opus 4.8 64d77ec5dc feat(draw): d2 diagram template — compile d2 source to SVG (T-494)
The d2 drawing template compiles a diagram's source to SVG through the d2
binary (resolved via the D-104 path layer), then paints it with the same
renderer the svg card uses. `clide draw --file x.d2` infers the type from
the extension; `.svg` files render directly. Template handlers now return
a DrawResult so a compile failure or an unresolved d2 surface as an honest
userError with an install hint, not a generic "no SVG". Real d2 0.7.1
verified end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:19:48 +02:00
jpmschweitzerandClaude Opus 4.8 67edf732f1 style(main): dart format the supporter-binaries boot call (T-495)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:29:21 +02:00
jpmschweitzerandClaude Opus 4.8 eac9612a5b feat(tools): Tools settings category + re-detect for supporter binaries (T-495)
ToolsSettingsExtension adds a Tools settings category — a path field per
tool (app.tools.<name>) plus a Re-detect action (tools.detect) — and keeps
the live resolver in sync as paths are edited (supporterBinariesFrom). en
+ nl catalogs. Completes the D-104 UI surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:28:46 +02:00
jpmschweitzerandClaude Opus 4.8 bd4a8332af refactor(env): per-tool override keys for supporter binaries (T-495)
Store each tool's override under its own key (app.tools.<name>) instead of
one map, so the settings panel can bind a plain text field per tool.
loadSupporterBinaries reads the per-tool keys plus a first-run marker and
keeps an explicit path; redetectSupporterBinaries backs the Re-detect
action. The resolver itself is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:22:41 +02:00
jpmschweitzerandClaude Opus 4.8 3be063d6da feat(problems): surface stale supporter-binary pins (T-495)
The Problems panel flags a configured tool path (app.tools) that no longer
points at a file — a real misconfig, echoing D-58's soft floor. A merely-
unfound optional tool isn't flagged (its use-time userError covers that).
Extracted to a pure supporterToolProblems(), flutter-tested (3 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:15:05 +02:00
jpmschweitzerandClaude Opus 4.8 62418b4010 feat(env): wire supporter-binary resolution at boot (T-495)
main resolves the override map (and first-run auto-detects, using the
primed login-shell PATH) right after bootSettings.load(), exposing the
process-wide activeSupporterBinaries that tool consumers read — the d2
template (T-494) will resolve its binary through it. Analyze clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:52 +02:00
jpmschweitzerandClaude Opus 4.8 0adccc5cd1 feat(env): first-run auto-detect + persist for supporter binaries (T-495)
loadSupporterBinaries reads the persisted tool→path override map; on first
run (key absent) it probes the known tools once and persists the result —
pinned thereafter, not re-detected each launch (D-104). Injected read/write
so it stays Flutter-free and dart-test covered (3 new cases). Boot wiring
next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:05:49 +02:00
jpmschweitzerandClaude Opus 4.8 dab0b3194e feat(env): supporter-binary resolver — explicit override → PATH → well-known (T-495)
SupporterBinaries resolves claude/d2/etc. to absolute paths (D-104): an
explicit override map first, then the login-shell/process PATH, then the
well-known bin dirs incl. Homebrew-on-Linux — the dir the standard PATH
expansion omits and a .bashrc-only brew shellenv misses (the D-104 gap).
detect() probes those to seed the override map (pinned, not re-probed each
launch); isStalePin flags a moved tool. Pure Dart, dart test (11 cases).
Settings storage + first-run detect wire on next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:03:12 +02:00
jpmschweitzerandClaude Opus 4.8 b1ec4afe15 feat(draw): data-lightbox tap-to-zoom on the drawing card (T-318)
A data-lightbox element gets a tap target (click cursor) over its region;
DrawingCard exposes an onLightbox callback, which the conversation wires to
open the whole drawing in a ClideLightbox (zoomable). Completes the
per-object overlay — captions + lightbox — and T-318's defined scope.
Widget-tested (the tap fires the callback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:40:26 +02:00
jpmschweitzerandClaude Opus 4.8 3e43746c17 feat(draw): per-object data-* caption overlay on the drawing card (T-318)
DrawingCard stacks a caption layer over the SVG: each data-label /
data-description annotation renders a themed caption just below its
element, mapped to pixel space via a shared svgViewportFit (refactored out
of the painter so the overlay and the paint use identical viewBox-fit
math). Display-only (IgnorePointer). Widget-tested. data-lightbox is
captured in the model; the tap-to-zoom interaction is the last follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:35:12 +02:00
jpmschweitzerandClaude Opus 4.8 78b0a7b8a4 feat(svg): extract per-object data-* annotations for the drawing-card overlay (T-318)
The builder captures data-label / data-description / data-lightbox on SVG
elements into SvgDocument.annotations, each with the element's AABB in
viewBox coordinates (accumulated transform applied — rect/ellipse/image/
line/poly/path bounded; groups skipped, text degenerates to its anchor
point). Feeds the Flutter caption/lightbox overlay next. Flutter-free,
covered by dart test (6 new cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:31:04 +02:00
jpmschweitzer 75f378ff20 chore(governance): Add D-105 to decision index in README.md
Auto-generated by pql when creating decisions/llm.md.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 408fe01648 fix(draw): drop now-redundant draw_dispatch import in the command test (T-318)
draw_commands.dart re-exports DrawingRegistry, so the direct import is
unnecessary; cleared the analyzer warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzer 6ebb4ab32b chore(docs): Move Vibe CLI analysis to docs/spikes/
Relocate online-mistral-analysis.md → docs/spikes/vibe-cli-integration-analysis.md
Update D-105 references to new path.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 7acea8baa0 feat(draw): wire the drawing card into the conversation — end to end (T-318)
The last wire: a DrawingMessage conversation item + the Claude-extension
subscriber on the `draw` channel (buildSvgDocument → inject), the
conversation renderer (DrawingCard), the fold/summary switches, the i18n
`drawing` label, and registering `clide draw` at boot (empty template
registry for now — primitive SVG works; d2/icon/compare/image handlers
plug in as they land). `clide draw --file doc.json` now renders a card in
the conversation. Render widget-tested; analyze + format clean across the
repo. Templates + per-object overlay remain; the CHANGELOG entry lands
with the user-complete feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzer a5529577f2 chore(vibe): Add AGENTS.md for Mistral Vibe hybrid operation
Distills CLAUDE.md guardrails and .claude/skills/ into Vibe's
instruction hierarchy, preserving Claude Code punch.

Includes full pql skill integration for vault queries and
project planning workflow.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-28 22:18:53 +02:00
jpmschweitzer cb8840aff3 feat(llm): Add Vibe CLI integration initiative (T-497, D-105)
- Add D-105: Support Vibe CLI as opt-in alternative to Claude Code CLI
- Add T-497 initiative with 6 children:
  - T-498: Adapt StreamJsonProcess + session management
  - T-500: Update config system for per-repo LLM selection
  - T-501: LLMDriver abstraction + per-repo config schema
  - T-502: Verify Vibe CLI permission + tool behavior
  - T-503: Hybrid LLM test suite + regression checks
  - T-504: pql improvements for hybrid agent workflow

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 14bbd1fd2d feat(draw): clide draw command — drive a drawing card from the CLI (T-318)
Mirrors image.show: a Flutter-free `draw` handler reads + JSON-parses the
document (injected reader), lowers it to SVG via the template registry,
and publishes {svg, label, description} on the `draw` MessageBus channel
for the Claude extension to inject. Honest errors — notFound (missing
file), userError (bad JSON / unknown template / no --file), toolError (no
live UI). Covered by dart test (7 cases). The extension subscription that
injects the card remains to wire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 ed5cd6e312 feat(draw): DrawingCard widget — SVG region + themed caption (T-318)
Display-only card (D-78): renders an SvgDocument in a framed, aspect-
ratio-sized region with an optional clide-themed label/description caption
beneath. The SVG is content (its own palette); the frame + captions are
chrome (SurfaceTokens). Widget-tested via the kernel fixture. Per-object
data-* overlay and lightbox remain follow-ons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 1fdba4aba5 feat(draw): template dispatch — lower a drawing-card doc to SVG (T-318)
resolveDrawingSvg lowers a DrawingCardDoc to an SVG string: primitive docs
use inline svg or read svgPath (via an injected reader); template docs use
a registered DrawingRegistry handler, so the d2/icon/compare/image children
plug in. Honest DrawErr on no source / unknown template / unreadable path /
empty output. Flutter-free, covered by dart test (7 cases). Not yet wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 1a0a69df5f feat(draw): drawing-card document envelope model + parser (T-318)
DrawingCardDoc + parseDrawingCardDoc read the `clide draw` JSON: card
label/description, primitive svg/svgPath, or a template name plus its
fields (for a handler to lower to SVG). Tolerant — a non-object payload
is null, blank strings are absent, never throws. Flutter-free, covered by
dart test (10 cases). First slice of the drawing-card core (D-103); not
yet wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 406da1e0c5 feat(svg): paint <image> via an injected resolver — completes the renderer (T-320)
paintSvg/SvgScenePainter/SvgView take an optional SvgImageResolver
(href → decoded ui.Image); image nodes draw into their dest rect. The
caller owns loading (file/asset/network), so the painter stays pure
rendering. Threaded marker + resolver through a small paint context.
Pixel-probe tested (image draws via the resolver; nothing without one).
The clide-owned SVG renderer is now feature-complete for T-320.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 718d2b39c2 feat(svg): marker/arrowhead rendering for the drawing-card renderer (T-320)
Collect <marker> defs (refX/refY, orient=auto, markerUnits) and the path
marker-start/mid/end refs in the builder, then paint them at the path
endpoints rotated to the path tangent (orient=auto). The viewBox→viewport
scale is approximated 1:1 (holds for d2's markers). d2 edges now render
with arrowheads. Covered by dart test (builder) + a flutter-test pixel
probe (the green arrowhead draws). image painting remains the last T-320
follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 91607d54d1 feat(svg): CustomPaint renderer for the drawing-card SVG engine (T-320)
paintSvg walks the typed SvgDocument and draws it: shapes → ui.Path
(rect/rrect/ellipse/line/poly/path incl. arcs), text → TextPainter
(anchor + baseline), per-node Affine transforms, group opacity via
saveLayer, and viewBox fit (xMidYMid meet). Exposes SvgScenePainter and
the SvgView widget. image-href (async) and markers/arrowheads are deferred
follow-ons. Pixel-probe tested under flutter test (8 cases, incl.
rendering the real d2 fixture). Completes the clide-owned SVG renderer
core (D-103); not yet wired into a card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 1a3486708c feat(svg): typed scene model + document builder for the drawing card (T-320)
The builder ties the five parsers together: parseXml → inlineStyles →
a typed SvgNode tree (group/rect/ellipse/line/poly/path/text/image) with
viewBox, per-node Affine transforms, and inheritance-flattened SvgStyle
(fill/stroke/font resolved to ARGB; opacity per-node). Tolerant — a
non-svg root yields an empty doc; defs/marker deferred. An end-to-end test
runs the whole pipeline against a real d2-rendered SVG fixture. Flutter-
free, covered by dart test (17 cases). No user-visible behaviour yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 ac387796c8 feat(svg): SVG colour + transform parsers for the drawing card (T-320)
parseSvgColor → packed ARGB (#rgb/#rgba/#rrggbb/#rrggbbaa, rgb()/rgba()
integer or %, common names, none/transparent → 0x0); null on unrecognised
so the caller can inherit. parseTransform → a composed 2-D Affine
(translate/scale/rotate[/about-point]/matrix/skew), applied left-to-right.
Both tolerant and Flutter-free, covered by dart test (22 cases). These feed
the typed node model next. No user-visible behaviour yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 fb96e5238f feat(svg): XML tokenizer + inline-style normalizer for the drawing card (T-320)
Zero-dependency, tolerant XML reader (elements/attrs/text/comments/prolog/
CDATA/entities; <style> read as raw text) producing a generic element tree.
The normalizer folds d2/graphviz's class-based <style> rules into inline
presentation attributes — cascade presentation-attr < tag < class < style —
then drops <style>/class/style, so the painter only ever sees inline attrs
(D-103). Flutter-free, covered by dart test (26 cases). No user-visible
behaviour yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 9cc3d68862 feat(svg): SVG path-data parser for the drawing-card renderer (T-320)
Parse the `d` attribute into an absolute-coordinate SvgPathSeg list,
lowering SVG shorthands to a small command set the painter can replay:
relative→absolute, H/V→lineTo, S→cubic and T→quad with control
reflection, arcs kept as arcTo. Tolerant — malformed tail returns the
understood prefix and never throws, so a bad diagram can't crash the
conversation. Flutter-free, covered by dart test. First slice of the SVG
engine (D-103); no user-visible behaviour yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 87634898c0 docs(design): finalize SVG subset + inline-normalize CSS for T-320
Pin the bounded SVG subset from a real d2 sample + our templates (markers
for arrowheads in; mask deferred to v1-ignore; foreignObject/filters/
gradients out, so mermaid isn't a launch target). Class styling resolves
via an inline-normalize preprocess (flatten <style> classes to inline
attrs) so the painter stays a pure attribute renderer. Tool-PATH for the
d2 binary now resolves via D-104.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 ed22160bbb chore(plan): persist T-495 id mapping
The pre-commit changelog export left ticket_idmap unstaged; commit it so
the T-495 label↔record_id mapping survives a rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 99cebc6d11 docs(governance): D-104 — explicit supporter-binary path overrides (T-495)
clide resolves external supporter binaries (claude, d2, future tools)
via an explicit user-scope tools: map (name → absolute path), consulted
first. First run auto-detects each tool once and writes the discovered
absolute paths into the map — a one-time populate, pinned thereafter, not
a per-launch heuristic. Escapes the login-shell-probe brittleness (T-439
misses brew when shellenv lives only in ~/.bashrc) while staying zero-
config for standard installs. User-scope only (D-93); generalizes D-58's
CLIDE_PQL_BIN override. Filed T-495; gates the d2 template (T-494).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 d036e6b9b0 chore(plan): persist drawing-card epic re-sequencing (D-103)
The pre-commit changelog export didn't re-stage the dependency edits from
this turn (blocker flip T-320↔T-318, template blockers, T-494). Stage
them explicitly so the re-sequencing persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 e2d9542fe8 docs(design): drawing-card wireframe set + SVG-substrate schema (T-317)
Seven Frame0 wireframes for the conversation drawing-card family — image
annotation, icon glyph, svg, d2, compare, graph, and the core dispatch
shell — plus the draft JSON schema refined from them. Schema follows the
SVG-substrate model (D-103): document envelope, bounded SVG subset, a
data-* Flutter overlay for captions/lightbox, templates lower to SVG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzerandClaude Opus 4.8 e9f394ff0f docs(governance): D-103 — drawing-card primitive layer is SVG (amends D-91)
The unified drawing card's primitive layer is SVG, not a bespoke
{type:"rect"} JSON vocabulary; the clide-owned CustomPaint SVG renderer
(T-320) is the engine the rest builds on. Templates lower to SVG; a thin
Flutter overlay carries label/description captions + lightbox, anchored
via data-* attributes; the graph template stays a live widget. Inverts
the former T-320 → T-318 dependency. Raised by the user: "if we are close
to svg, are we not better off extending svg instead?"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:18:53 +02:00
jpmschweitzer ab9f93b541 Update README.md
changed readme to mention added windows build
2026-06-28 15:37:57 +02:00
jpmschweitzerandClaude Opus 4.8 89b7f99a8d build(ci): make dugite-check — track dugite-native upstream for security (T-88)
D-59 requires tracking dugite-native (bundled git) releases for security
updates, but it lived only in the D-record prose with no mechanism (clean-house
flagged RULE-SUNSET-WITHOUT-TICKET). Add ci/check_dugite_version.sh + a
`make dugite-check` target: compares the Makefile DUGITE_VERSION pin against the
latest desktop/dugite-native release and loudly flags CVE / security mentions in
the notes. Informational (not a push gate) — the bump itself is manual per D-63
and automated by T-25.

The script header is the maintenance home (dugite is fetched, not built, and
native/dugite/ is gitignored, so there's no BUILD.md to record it): cadence
(quarterly / on a git CVE), advisory subscriptions, and the bump procedure.
D-59's cost line + a Makefile comment now point at the mechanism. T-88 stays
open as the recurring calendar; first check (2026-06-28) shows v2.53.0-3 current.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:12:50 +02:00
jpmschweitzerandClaude Opus 4.8 066197c5e1 fix(i18n): stop warning on missing tool.name.* keys for proper-name tools (T-493)
Tool display names are open-ended (Bash, Grep, Glob, ScheduleWakeup, MCP tools,
…) — they intentionally have no catalog key and fall back to the raw name, so a
miss isn't a gap to fix. The conversation pane was logging an i18n "missing key"
warning for each, cluttering the output dock.

Adds a `warnIfMissing` flag to I18nService.string (default true, so fixed UI
strings still warn on a real gap) threaded through the ClideSettings facade;
_toolNameLabel passes false. Display is unchanged — the placeholder already
rendered the raw name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:58:12 +02:00
jpmschweitzerandClaude Opus 4.8 e5976ad6b1 test(menubar): cover githubGet against a loopback server (T-47 P1)
The injectable-fetch tests skip the real HTTP path; exercise githubGet's
200-body and non-200-throws branches against a localhost HttpServer so no test
hits the network and the gate stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:18:28 +02:00
jpmschweitzerandClaude Opus 4.8 7c7a91545e feat(menubar): manual "Check for updates" in the About box (T-47 P1)
Help → About gains a "Check for updates" button that fetches the latest GitHub
release, semver-compares it to clideVersion, and shows the result inline:
up-to-date, available (with a tappable link to the release notes), or a clear
error. clide's first and only outbound HTTP call — a plain GET with no user
data, run ONLY on this explicit tap, never on a launch path or a timer. So it's
D-64-clean with no amendment; a background/periodic poll stays deferred (would
need the narrow opt-in amendment first).

The fetch is injectable so no test touches the network. compareSemver handles
2.3.10 > 2.3.9 and ranks pre-releases below their release. Closes T-492 (P1);
the release-channel CI for downloadable signed packages is T-491, and download/
apply (P2/P3) depend on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:17:29 +02:00
jpmschweitzerandClaude Opus 4.8 095c45a023 feat(markdown): live-sync read-mirror of the open editor buffer (T-36)
D-50 behavior 4: opening a renderable .md in the editor now auto-reveals the
context-panel reader, which mirrors the buffer read-only and re-renders as the
user types — rather than a one-shot disk read.

The extension reveals the tab on editor.opened (renderable only — D-50 behavior
5 leaves non-.md files alone). The viewer owns the mirror: on mount it picks up
the active buffer (editor.read, no id), enters mirror mode on editor.opened /
active-changed for a renderable file, re-reads the in-memory buffer on
editor.edited, and drops the mirror (with its edit affordance back) on a disk
load or a switch to a non-renderable buffer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:08:31 +02:00
jpmschweitzerandClaude Opus 4.8 84f4b20cb8 feat(claude): nudge fresh sessions to load the pql + clide skills (T-490)
A fresh Claude session now gets a one-line preamble (clideSkillsNote) telling it
to reach for the bundled pql + clide skills from its first turn rather than
rediscovering the workflows. Layered onto clideContextNote in the single
--append-system-prompt.

Gated on a NEW session (!spec.resume && !spec.isFork): new tabs and the
post-/clear respawn spawn with resume:false and get it; the account-change
respawn (T-480) and real resumes carry prior context (resume:true), and forks
inherit their source — none are re-nagged. Both skills already ship (pql
user-scope, clide repo-scope), so no precursor was needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:53:08 +02:00
jpmschweitzerandClaude Opus 4.8 a46677facf feat(welcome): initialize a non-repo folder as a project (T-489)
Closes the new-project story (T-486). The dead-end "not a git repo" dialog now
offers to initialize the folder: project.init runs git init + a non-clobbering
scaffold, then opens + announces on projectCreatedChannel so the account
roadblock fires — the same path a brand-new project takes. Adds initExistingProject
+ the `clide project init [--dir]` verb (default: the current workspace).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:42:02 +02:00
jpmschweitzerandClaude Opus 4.8 25430686cc feat(welcome): New project flow + per-repo account roadblock (T-488)
The UI half of the new-project flow (story T-486). A "New project…" welcome
action opens a dialog (location + name) that dispatches project.new (T-487),
opens the result, and announces it on projectCreatedChannel. The Claude
extension consumes that and shows the account roadblock — the embedded
per-workspace picker + accounts list, so a fresh project gets bound to an
account (or Default) right at birth.

The two halves stay decoupled: the welcome builtin only publishes the event
(no claude import); the claude builtin owns the account dialog. Only freshly-
created projects announce, so existing opens are never prompted.

Closes T-488. The non-repo "initialize as a project" path (T-489) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:35:30 +02:00
jpmschweitzerandClaude Opus 4.8 adaf8966dc feat(cli): clide project new — create + git-init a new project (T-487)
The backend half of the new-project flow (story T-486). createNewProject
validates the name, makes <parent>/<name>/, runs git init (injected from the
toolchain in main.dart so the handler stays Flutter-free), and writes a minimal
scaffold (.gitignore + a CLAUDE.md stub). The project.new verb wraps it; --dir
defaults to the current workspace's parent so a new project lands beside it.

Create-only by design — opening the new workspace and the account roadblock are
the UI flow's job (T-488). Closes T-487.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:23:18 +02:00
jpmschweitzerandClaude Opus 4.8 b9fc720f97 feat(git): GitClient.init for the new-project flow (T-487)
clide treats a git repo as the workspace, so creating a new project starts with
`git init`. Add GitClient.init — `git init -b <main>` in workDir, deterministic
default branch, idempotent on an existing repo. The first primitive of the
new-project flow (T-486); the create-dir + scaffold service and the
`clide project new` verb build on it next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:19:10 +02:00
jpmschweitzerandClaude Opus 4.8 6d7b96f115 chore(plan): re-scope T-486 — account picker at new-project init, not welcome
Per the design pivot: the account choice is only a real decision at project
birth, so it belongs as a roadblock in the new-project / init-in-a-new-dir flow
(evolving the dead-end _NotARepoDialog), not a welcome-screen list. Opening an
existing project never prompts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:06:42 +02:00
jpmschweitzerandClaude Opus 4.8 8a9167bdfd test(claude): cover remove-nonexistent + list live-update for the 95% margin
Two more real branches the account feature left uncovered: removing an
unregistered account (the no-such-account error) and the registry list
rebuilding when a CLI-side registration notifies the shared settings store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 01:19:50 +02:00
jpmschweitzerandClaude Opus 4.8 da82b48f9b test(claude): cover the per-repo account branches to clear the 95% gate
Adds the missing-line coverage the epic's new code left uncovered: Account /
DetectedAccount value equality, the set/unset no-workspace error branches, the
login dialog's escape-to-close, and the settings controls' live-update /
no-workspace / duplicate-add / re-login paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 01:13:03 +02:00
jpmschweitzerandClaude Opus 4.8 53b93f092b test(claude): scope the Claude-category select assertion to the defaults fields
The Account section (T-482) adds custom-kind controls to the Claude settings
category, so the T-457 test can no longer assert that *every* field in the
category is a select. Scope the kind/options check to the new-session-default
fields (model/effort/permission) it was actually about.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:57:26 +02:00
jpmschweitzerandClaude Opus 4.8 996b10d3ec feat(claude): account login pane — host claude login in a modal terminal (T-485)
Completes the `login` verb + the UI add/re-login affordances. The
accountActionChannel 'login' action opens ClaudeLoginDialog — a modal hosting a
TerminalPane that runs `CLAUDE_CONFIG_DIR=<dir> claude login`, so the CLI drives
the OAuth browser flow and credentials land in that account's config dir (D-64:
one CLI-initiated browser flow, on explicit action).

TerminalPane gains optional argv/env/cwdOverride (default stays the login
shell), and its pane.spawn carries env when set. The login pane reuses the
TerminalPane *widget* — a code import, not a runtime dependency, since it spawns
through the always-present pane.spawn IPC rather than the terminal extension.

Tests cover the parameterized spawn args and the dialog's host wiring (title,
CLAUDE_CONFIG_DIR, close). Closes T-485.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:52:41 +02:00
jpmschweitzerandClaude Opus 4.8 222d4ae5e3 feat(claude): per-repo account badge in the Claude pane chrome (T-481)
The always-visible affordance for the multi-account epic: a compact badge in
the pane header showing which account this workspace is bound to (or "default"),
colour-tinted per account so two windows are distinguishable at a glance. Tapping
opens a picker of the registered accounts + Default; selecting binds/unbinds via
the shared bindWorkspaceAccount helper (respawn + lock-sync follow on the bus).
Hidden when no accounts are registered, so it adds no chrome for users not using
the feature.

accountAccent derives the tint by hashing the name into a fixed set of theme
tokens — never an arbitrary colour, so the palette stays theme-owned.

The welcome-view accounts section (the other half of T-481) is split to T-486:
the welcome builtin is intentionally decoupled from feature builtins, so it needs
a welcome-section contribution point rather than importing claude directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:43:40 +02:00
jpmschweitzerandClaude Opus 4.8 5c31190c4f feat(claude): Settings → Claude accounts registry list (T-482 part 2)
Completes the account settings surface with the global Accounts list under the
Claude category: each registered account shows a sign-in dot, name, and config
dir, with re-login and remove affordances plus an inline add-account field.

- Sign-in status: accountIsSignedIn, a read-only probe (a .credentials.json, or
  an oauthAccount marker in .claude.json; under-reports on macOS keychain).
- Add: registers ~/.claude-<name> and publishes a login action (T-485 opens the
  pane).
- Remove: registry-remove, guarded while a workspace is bound — matching the CLI
  `account remove`. --purge dir deletion stays on the CLI flag.

All management routes through the AccountRegistry + accountActionChannel, and
the list rebuilds live off the settings notifier. en/nl strings + widget/probe
tests added. Closes T-482.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:35:47 +02:00
jpmschweitzerandClaude Opus 4.8 c3efcf3855 feat(claude): Settings → Claude per-workspace account picker (T-482 part 1)
A custom settings control under the Claude category: a dropdown of the
registered accounts plus Default. Picking one binds (or unbinds) this
workspace via the AccountRegistry and publishes set/unset on
accountActionChannel — the same channel the CLI verbs use, so the session
respawns onto the account (T-480) and the IDE lock re-syncs (T-479). It reads
live off the shared settings notifier, so a CLI `account set` updates the
dropdown too. Empty states cover no-workspace and no-accounts, each pointing at
the CLI.

Registry writes set the in-memory binding synchronously then flush, so the
control publishes the bus event before the disk write completes.

Part 2 (the global Accounts registry CRUD list + sign-in probe) is still open
on T-482. en/nl strings added; widget test covers the states + bind/unbind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:46:43 +02:00
jpmschweitzerandClaude Opus 4.8 e86543d476 fix(ipc): write the /ide discovery lock into bound-account config dirs (T-479)
A `claude` started with CLAUDE_CONFIG_DIR=<dir> looks for clide's /ide
discovery lock under <dir>/ide, not ~/.claude/ide — so today a per-repo
account session (T-476) can't reach clide's IDE bridge.

McpServer now reconciles a SET of lock paths instead of one: the default
~/.claude/ide always, plus the bound account's <dir>/ide when this workspace
is bound (boundConfigDir, injected from main.dart via the AccountRegistry).
syncDiscoveryLocks() writes/removes locks to match the active set; it runs on
start, once post-boot when the registry becomes resolvable, and on every
accountActionChannel event, so binding/unbinding adds/removes the account lock
at runtime. stop() sweeps every path written — no orphans. Lock content +
0600 are identical across dirs.

Acceptance 1-4 and 6 are covered by mcp_server_test (multi-dir write, no-binding
regression, dynamic add/remove, full cleanup); #5 (a live claude resolving the
bridge end-to-end) is manual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:25:19 +02:00
jpmschweitzerandClaude Opus 4.8 34b9690005 feat(claude): respawn panes on account set/unset + safe --purge (T-480 part 2)
The extension consumer for the per-repo account verbs, making set/unset
fully functional. The Claude extension subscribes to accountActionChannel:

- set / unset → ClaudeSessionOrchestrator.respawnForWorkspace(cwd): closes the
  workspace's solo sessions (awaiting real process death, T-437) and re-spawns
  each on the same id with --resume, so the conversation continues under the
  newly-bound CLAUDE_CONFIG_DIR (resolved at spawn by agentBootstrap). Team and
  forked sessions are skipped — re-joining the broker / re-forking on an account
  swap is out of scope; they adopt the account on their next natural spawn.
- remove --purge → deletes the config dir behind isPurgeableAccountDir, a strict
  guard that only ever removes a ~/.claude-* directory that is a direct child of
  $HOME. The purge payload now carries the dir (the account is gone from the
  registry by publish time).

login still only publishes its action — spawning the `claude login` terminal
pane needs argv+env terminal-pane support and is split to T-485.

Covered: respawnForWorkspace (respawn solo, skip fork/other-repo) and the purge
guard's accept/reject matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:18:49 +02:00
jpmschweitzerandClaude Opus 4.8 744b6cff43 feat(cli): clide claude account verbs — add/list/login/set/unset/remove (T-480 part 1)
The CLI half of the multi-account feature (epic T-476; D-6 parity). A new
`claude.account` dispatcher command multiplexes the six sub-verbs over an
injected, Flutter-free AccountStore port (runs under `dart test`):

- add <name> [--dir]   register (default ~/.claude-<name>); idempotent, clear
                       conflict error
- list                 {accounts, boundAccount (this workspace), detected}
- set <name>           bind this workspace (persists)
- unset                clear this workspace's binding
- remove <name> [--purge]  registry-remove; refuses while any workspace is
                       bound
- login <name>         (publishes the login action)

Registry reads/writes go through the user-scope SettingsStore; side-effects
that only the UI layer can do — respawn on set/unset, the `claude login`
terminal pane, and the --purge rm — are published on accountActionChannel for
the Claude extension to consume (that consumer is T-480 part 2). main.dart
adapts the real AccountRegistry to the port and registers the command alongside
image.show / status.

Adds SettingsStore.keysAt (binding enumeration) and AccountRegistry.boundName /
boundAccountNames. No changelog yet — set/unset don't auto-respawn until part 2,
so the feature isn't user-complete. Verb behaviour + payloads + the in-use
guard are unit-tested against a fake store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:30:01 +02:00
jpmschweitzerandClaude Opus 4.8 1fa7dee863 feat(claude): spawn hosted sessions under the bound account's CLAUDE_CONFIG_DIR (T-484)
The load-bearing piece of the multi-account epic (T-476): a bound workspace's
hosted claude now spawns with CLAUDE_CONFIG_DIR set to that account's dir, so
it runs under the bound account end-to-end. Every hosted session (primary /
secondary / fork / teammate) inherits it — the orchestrator already routes all
spawns through agentBootstrap.

- New pure resolver claudeConfigDirForWorkspace(cwd, boundConfigDir, env):
  bound account dir > parent CLAUDE_CONFIG_DIR (respect the launcher) > null
  (Claude defaults to ~/.claude). The registry is injected as a plain lookup
  so agent_bootstrap stays Flutter-free (its tests run under `dart test`).
- agentBootstrap merges CLAUDE_CONFIG_DIR BEFORE base, so an explicit
  SpawnSpec.env override still wins (override > binding > parent > unset); the
  key is omitted entirely when the resolver returns null.
- Orchestrator carries an optional AccountRegistry; the claude extension builds
  it from ctx.settings. Null in tests → no injection (unchanged behaviour).

No way to SET a binding yet (that's the CLI T-480 / settings UI T-482), so no
changelog entry — the mechanism is in place, the surface lands next. Unit tests
cover the resolver's four states and the envDelta precedence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:00:06 +02:00
jpmschweitzerandClaude Opus 4.8 a8d321f344 feat(claude): per-repo account registry + storage foundation (T-483)
Foundation for the multi-account epic (T-476): a Flutter-free AccountRegistry
over the app-scope SettingsStore (per-user, never committed to a repo).

- `app.claude.accounts` — the {name, dir} account list (dir = CLAUDE_CONFIG_DIR).
- `app.claude.account.<hash>` — per-workspace binding to an account name; the
  hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket path, trailing
  separators stripped so /repo and /repo/ map alike.
- accountForWorkspace(cwd) resolves an account or null (a binding to a removed
  account degrades to null = Claude's default, never errors).
- probeExistingAccountDirs(home): read-only bootstrap probe for adoptable
  ~/.claude-* config dirs (welcome-view UX is T-481).

Keys are app.-prefixed because SettingsStore requires a scope prefix; app scope
already provides the per-user persistence T-483 wants (T-356 will consolidate
more state later but isn't a hard blocker). No spawn/UI/CLI — downstream
tickets. Unit tests cover CRUD, the disk round-trip, hash/trailing-slash
mapping, independent bindings, and probe filtering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:42:39 +02:00
jpmschweitzerandClaude Opus 4.8 c6adccc5de build(make): route build through build-<os> instead of duplicating the flutter build
`build` ran `flutter build $(FLUTTER_OS)` inline while build-linux/-macos/
-windows ran the same command separately — two places to keep in sync. Make
`build` depend on `build-$(FLUTTER_OS)` so the per-OS target is the single
source of the flutter-build command; `build` just adds the C-client bundling
on top. `make install` → build → build-<os>, and `make build-linux` still
works standalone (release.sh + CI reference it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:32:44 +02:00
jpmschweitzerandClaude Opus 4.8 679b5ba9d9 feat(cli): clide instances / instance verbs for instance discovery (T-247)
Closes the observability half of T-247: a way to find and identify running
clides. `clide instances` scans the runtime socket dir, probes each live
*.sock, and prints its identity (version/pid/workspace/socketPath) as jsonl;
dead sockets are skipped. `clide instance` reports the one you're connected
to. Combined with CLIDE_SOCK honoring (this same ticket), you can now list
instances and pin the CLI to a chosen one.

Server: a new `instance` dispatcher command (registered in buildDispatcher
with the live workspace/pid/socket) returns the identity map; added to the
argv translator's umbrella set so a bare `instance` token routes to it.
Client: a POSIX dir-scan in clide.c (Windows stub until it ships).

Tests: e2e `instances` lists the live test server with its identity; a
cc-free unit test covers the `instance` command shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:51:13 +02:00
jpmschweitzerandClaude Opus 4.8 ceab497ba8 fix(ipc): sweep dead orphan sockets from the runtime dir on startup (T-247)
The runtime socket dir accumulated orphaned *.sock nodes from crashed
instances — only the current workspace's own path was ever cleaned. Add a
best-effort startup sweep that probes every *.sock in the dir and unlinks
only the dead ones; live instances (something answers) and unresponsive
nodes (possibly hung) are left alone. Runs before bind, alongside the
existing per-workspace stale-unlink. Never blocks our own startup on a
sweep failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:15:05 +02:00
jpmschweitzerandClaude Opus 4.8 6af980f31c fix(cli): honor CLIDE_SOCK as an explicit target, fail loudly if dead (T-247)
The `clide` shell client only ever computed the socket path from the
workspace's FNV-1a hash — it never read CLIDE_SOCK, so the var the app
exports to spawned agents was a no-op and a bogus CLIDE_SOCK was silently
ignored (it still hit the discovered instance). Now: when CLIDE_SOCK is set
the client connects to it directly, beating discovery, and a dead socket
aborts with EX_UNAVAILABLE rather than falling back to a different instance
(the split-brain footgun). Unset → the deterministic per-workspace path
(D-70) as before. The server keeps deterministic binding; this is a
client-side targeting override only.

Also fixes a pre-existing -Wstringop-truncation warning in
find_workspace_root (strncpy+manual-null → snprintf).

Tests: the e2e suite now clears the inherited CLIDE_SOCK so discovery tests
stay hermetic (the suite may run inside a clide instance), plus two new
tests — a valid CLIDE_SOCK pins from a non-git dir, a dead one fails loudly
and returns no data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:11:55 +02:00
jpmschweitzerandClaude Opus 4.8 3cbe0e6ec6 refactor(claude): drop the redundant Team-tab account budget (T-158)
After live review: the shared account /usage budget read as redundant on
both the Activity and Team tabs. Usage is per-account and can't be split per
member, so one place is enough — it lives only on the Activity tab, next to
the refresh control that fetches it (T-415). Removes the Team-tab account
card, its usage prop wiring, the team.section.usage/team.usage.shared i18n
keys, and the now-obsolete team_tab test. The three-tab card facelift stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:56:09 +02:00
jpmschweitzerandClaude Opus 4.8 a72906d25d chore(plan): reconcile T-477/T-478 label collision from the merge
The merged T-476 breakdown reused T-477/T-478 (already held by the
window-size fix + Summer Night theme). Relabel the two per-repo-account
children to T-483/T-484 (identities/record_ids unchanged), preserving the
labels already referenced in CHANGELOG and pushed commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:45:10 +02:00
jpmschweitzerandClaude Opus 4.8 026156261c Merge origin/main: T-476 per-repo-account breakdown
Union-resolved the .pql/changelog logs (append-only, idempotent INSERT … ON
CONFLICT DO UPDATE — latest-updated wins on replay), keeping both sides:
this session's T-158 changes and origin's T-476 6-child breakdown. pql.db
rebuilt clean (2785 statements).

NOTE — label collision to reconcile (record_ids intact, labels only):
the breakdown reused T-477 and T-478, already held by the window-size bug
and the Summer Night theme. Reconcile with `pql ticket relabel`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:36:03 +02:00
jpmschweitzerandClaude Opus 4.8 763d628fc9 feat(claude): shared account budget in Team tab + meta-sidebar card facelift (T-158)
Implements T-158 option A: the forwarded /usage budget (already parsed for
the Activity tab, T-415) now also renders in the Team tab as a single
ACCOUNT card, captioned "shared across the team". Usage is per-account —
every team session shares one ~/.claude login — so it's shown once, not
split per member (which would just repeat identical numbers).

Facelift: the Activity, Team, and Config tabs now render their sections as
elevated cards (panelHeader fill, dividerColor hairline, 6px radius) under
small-caps mono headers, matching the settings overlay's card design
(settings_category_view). Shared helpers — metaSectionHeader / metaCard /
metaCardRow — live in meta_sidebar/models.dart; the SESSION control strip
and the pinned Config SETTINGS block adopt the same card. The shared
ClideAccordion (also used by decisions/tickets) is left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:31:50 +02:00
Jeroen SchweitzerandClaude Opus 4.7 3f627c5b17 chore(plan): break T-476 down into 6 children under an epic
Convert T-476 from story to epic and file the implementation slices:
T-477 storage/registry/migration, T-478 spawn-time CLAUDE_CONFIG_DIR
injection, T-479 multi-dir /ide lock writer, T-480 clide CLI verbs
(D-6 parity), T-481 pane badge + welcome accounts UI, T-482 settings
rows. Blocker graph wired (T-356 -> T-477 -> fan-out -> T-480 -> UI).
Epic body refined with the proven mechanism, surveyed alternatives,
concrete code touchpoints, and acceptance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:50:57 +02:00
jpmschweitzerandClaude Opus 4.8 c4be179a87 chore(plan): record T-158 decision — option A (shared-account team-tab budget)
Locks scope to the single account-wide budget section in the team tab; declines
the per-member split (not meaningful under one shared account) and the
close-as-done option. Not started.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:01:21 +02:00
jpmschweitzerandClaude Opus 4.8 367792490d chore(plan): refine T-158 (team-sidebar usage budget) implementation plan
Grounded against the current tree: T-415 already ships the account budget
(parseUsageText/ClaudeUsage + Activity tab). Key finding — usage is
per-ACCOUNT (one shared ~/.claude login across all sessions), so a literal
per-member split would repeat identical numbers and isn't meaningful even
under T-476 (per-repo, not per-session, login). Refinement recommends a
single shared-account section in the team tab (resolves Q-34), lists the
exact files to touch, alternatives, and acceptance. No code yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:59:50 +02:00
jpmschweitzerandClaude Opus 4.8 259562f8cc docs(git-commit): document the release tagging step (T-393)
The "Cutting a release" ritual moved the changelog and bumped pubspec but
never mentioned tagging — which is how tags silently lapsed from v2.2.0
through v2.8.0. Add step 6: run `make release` (verify + gate + annotated
tag), then push with --follow-tags, and state the rule that every released
version must have a matching git tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:27:13 +02:00
jpmschweitzerandClaude Opus 4.8 9a5f5b2ab5 build(hooks): widen pre-push fast path to test/ci/.githooks + guard test (T-393)
The fast-path regex matched only `^(lib/|pubspec\.)`, so a push that ONLY
changed a test, a ci/ gate script, or the pre-push hook itself skipped the
entire ~2min suite — exactly the paths most able to break the suite or the
gate went unchecked. Widen the trigger to include test/, ci/, and
.githooks/, and extract it to a single `trigger_re` variable. Also drop the
header comment's false claim that "release CI runs it forced on a tagged
version" — there is no release CI.

Add test/tooling/pre_push_hook_test.dart: it reads the live trigger_re from
the hook and asserts the load-bearing dirs force the full gate while
docs/assets ride along — so the regex can't be silently narrowed again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:26:52 +02:00
jpmschweitzerandClaude Opus 4.8 c1d91e57d2 build(ci): real release finalizer + make release target (T-393)
ci/release.sh was a stub that printed a goreleaser/sidecar TODO and
exited 64 — both the sidecar and goreleaser are dissolved (D-56), so it
could never run. Replace it with a finalizer for the single-process app:
read the version from pubspec, assert CHANGELOG has a dated section for it
and the tree is clean, run the full gate (make push-check), then create
the annotated vX.Y.Z tag if missing. Never pushes — prints the
--follow-tags push + build commands. Add a `make release` wrapper so it's
driven through the Makefile like the other gates (CLAUDE.md tooling rule).

This closes the loop that left releases untagged: running `make release`
after the release commit can no longer forget the tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:26:37 +02:00
jpmschweitzerandClaude Opus 4.8 b7db22924d feat(theme): canonical theme/namespace lists + ship Summer Night (T-371, T-478)
The bundled-theme list and the Tier-0 i18n namespace list were each
hand-maintained in 3+ places that had drifted: the testmode harness loaded
8 themes (catppuccin silently unvalidated) while the app and contrast gate
loaded 10, and the i18n gate checked 4 of the shipped namespaces. Export
one canonical const each — kBundledThemePaths and kTier0Namespaces — and
have the app, the testmode harness, and the a11y gates iterate them.

Drift-proof meta-assertions:
- contrast gate fails if any theme YAML on disk is absent from
  kBundledThemePaths (so a theme can't sit unvalidated).
- i18n gate derives its subjects from assets/i18n/en_us/ at test time and
  asserts en/nl key parity for every shipped catalog (26, was 4), plus that
  every kTier0Namespaces entry has a shipped catalog.

Surfaced summer-night.yaml: a legacy-ported palette on disk, unbundled and
never contrast-checked. Per the maintainer's call, ship it: flesh the
palette to full token + syntax coverage (honouring the v1.2 colours, clide
derivation pattern for the new keys) and add a structurally identical
summer-night-hc sibling that clears the strict extended gate. Both pass.

No licenses.yaml change: summer-night is clide's own (ported from legacy
clide v1.2 under legacy/, MIT); the only third-party palette, Catppuccin,
is already acknowledged; no fonts added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:31:16 +02:00
jpmschweitzerandClaude Opus 4.8 1e49f3e1d9 fix(editor): collapse the workspace split when the last buffer closes (T-459)
EditorRegistry.close() guarded its active-changed emit on `_activeId !=
null`, so closing the LAST buffer (active clears to null) emitted only
editor.closed — never the active-changed(id:null) the editor extension
listens for to call closeEditor(). editorOpen stayed true and the top
split sat orphaned over the Claude pane. Always emit active-changed when
the active buffer is removed, including the cleared-to-null case; the
slot renderer already collapses correctly once editorOpen flips false.

The existing extension test fabricated the null active-changed event, so
it passed despite the registry never emitting it — that gap is why the
bug shipped. Add a registry test that drives the real close() path, plus
a slot_host widget test asserting the split (drag handle) drops out and
the primary pane fills the column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:49:12 +02:00
jpmschweitzerandClaude Opus 4.8 8c9c881d81 fix(welcome): open Linux window at 1600x900 to clear footer overlap (T-477)
At the 720p default the vertically-centred welcome content slid under the
floating version/theme status line (Positioned bottom:24), overlapping the
tips card. The taller default gives the centred block clear bottom margin
and matches the macOS default size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:14:05 +02:00
jpmschweitzerandClaude Opus 4.8 3aebfa7ec3 release v2.8.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 07:46:10 +02:00
jpmschweitzerandClaude Opus 4.8 ea340ef141 fix(i18n): localize remaining Claude-pane strings (T-462)
Completes i18n coverage of the conversation surface — the strings the
T-464 migration left as "decorative" English:

- running-indicator verbs (Pondering/Conjuring/… → catalog, Dutch added)
- pane chrome title (primary / secondary N) and banner role
- tool-card titles (Edit → Bewerken) via a tool.name.* key set, with
  command/proper-name tools (Bash, Grep, Glob, LS) falling back to the
  raw name
- collapser step/edit/agent counters
- the folded-activity ticker (result/error/thinking/image + tool name)

English degrades to the placeholder where a key is absent, so untranslated
or MCP tool names render unchanged. en_us and nl_nl stay at key parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:24:34 +02:00
168 changed files with 19037 additions and 416 deletions
+1
View File
@@ -99,6 +99,7 @@ Cutting a release is its own commit. In a single commit:
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
4. Run `make gen-build-info` so `assets/licenses.yaml` `self.version:` re-syncs from pubspec (it's auto-rewritten by every build but commit the fresh state). Stage the resulting diff alongside step 3.
5. Commit subject: `release vX.Y.Z`.
6. **Tag it.** After the commit lands, run `make release` — it verifies the version/changelog/clean-tree invariants, runs the full gate, and creates the annotated `vX.Y.Z` tag. Then `git push origin main --follow-tags` to publish the commit and the tag together. **Every released version must have a matching `git tag`** — a CHANGELOG heading without a tag is an incomplete release (T-393: tagging had silently lapsed from v2.2.0 through v2.8.0). Don't hand-roll the tag; `make release` keeps the tag, gate, and version in lockstep. If you tag by hand, use an annotated tag (`git tag -a vX.Y.Z -m "clide vX.Y.Z"`); never push a release commit without its tag.
`pubspec.yaml` is the single source of truth for the version. Every `make` build/run/test target regenerates `lib/src/build_info.g.dart` (gitignored) and rewrites `assets/licenses.yaml` `self.version:` from it — so the Flutter app sees the current version everywhere without manual sync. Bumping `pubspec.yaml` and the changelog out of sync is the mistake this rule prevents.
+20 -12
View File
@@ -5,14 +5,16 @@
# Bypass: never. If this runs slowly, fix the slow test; don't reach
# for --no-verify (git-commit skill forbids it).
#
# Fast path (T-348): run the full ~2min test suite only when the push touches
# lib/ (app + runtime Dart source) or pubspec.* (deps / version). test/,
# assets/, docs, and tooling changes ride along with a lib change in practice,
# and an otherwise-skipped push is covered by the next one that does touch lib.
# The full suite is always available via `make push-check`, and the release CI
# runs it forced on a tagged version. So a lib/pubspec-free push runs just the
# instant decisions + changelog gates. A state we can't classify (unfetched
# remote, new branch) runs the full gate.
# Fast path (T-348, widened T-393): run the full ~2min test suite when the push
# touches lib/ (app + runtime Dart source), pubspec.* (deps / version), or the
# things that can themselves break the suite or this gate — test/, ci/, and
# .githooks/. (The old regex matched only lib/ and pubspec.*, so a push that
# ONLY changed a test, a ci/ gate script, or this hook skipped the whole suite.)
# Pure assets/docs changes still ride along with the next lib-touching push.
# There is no release CI — the full suite is only ever run here or via
# `make push-check`. So a push touching none of the above runs just the instant
# decisions + changelog gates. A state we can't classify (unfetched remote, new
# branch) runs the full gate.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
@@ -40,16 +42,22 @@ while read -r _local_ref local_sha _remote_ref remote_sha; do
changed+=$'\n'"$(git diff --name-only "$base" "$local_sha")"
done
# Run the full gate when lib/ (app + runtime source) or pubspec.* (deps /
# version) is touched, or when we couldn't classify above.
# Paths that force the full gate: source (lib/), deps/version (pubspec.*), and
# the dirs that can themselves break the suite or this gate (test/, ci/,
# .githooks/). Single source of truth — test/tooling/pre_push_hook_test.dart
# reads this exact pattern, so narrowing it fails that test (the T-393 guard).
trigger_re='^(lib/|test/|ci/|\.githooks/|pubspec\.)'
# Run the full gate when a trigger path is touched, or when we couldn't classify
# above.
needs_gate=1
if [[ "$force_full" -eq 0 ]]; then
trigger_files="$(printf '%s\n' "$changed" | grep -E '^(lib/|pubspec\.)' || true)"
trigger_files="$(printf '%s\n' "$changed" | grep -E "$trigger_re" || true)"
[[ -z "$trigger_files" ]] && needs_gate=0
fi
if [[ "$needs_gate" -eq 0 ]]; then
echo "==> pre-push: no lib/ or pubspec change — decisions + changelog gates, skipping tests"
echo "==> pre-push: no source/test/ci/hook/pubspec change — decisions + changelog gates, skipping tests"
make decisions-validate changelog-gate
else
echo "==> pre-push: make push-check"
+21
View File
@@ -35,3 +35,24 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN09R21H3AWR2Q2ZTSGNSW', '2026-06-12 03:16:40', '2026-06-12 03:16:40', NULL, '7e00348c10432c65b03f9dce36520e48', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', '06FBKN2MP35NPPK1BRDYY2M428', '2026-06-12 03:16:44', '2026-06-12 03:16:44', NULL, 'a6b1c20279ac30f692a30c802cf8f3e5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN4QVFVE51MY2N0CWCVXHM', '2026-06-12 03:16:49', '2026-06-12 03:16:49', NULL, '9063328f18ba32c0737997ac9af0911a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSM0PRGYR61R0NWYAT9VDC', '06FFW49VYMPF18PYQXD9PCMHN8', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'a5146a3cfb2f6a3fd904224150836336', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49W3V175EM4F8ZHC6JFM4', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'd57042b235b73ed2d25252d5253e6e4a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49W6GP535GR8XD1XEHYWG', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, '07fe1945cb3e60c43cce0989366af8fb', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49W95HJK08BCRQSWFZBF4', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'e4484230e2202044baaa5ce7a2438bbb', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', '06FFW49W95HJK08BCRQSWFZBF4', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, '24cc5602737e75d30377522f38b3e7b5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W95HJK08BCRQSWFZBF4', '06FFW49WBTT3ESK6QG0XZ1VN2G', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, '51f4759153b30d2a62ed6c69ab62b84d', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49WEE5N4PESF5G1G5JRHR', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'b348cfc357b73fb28366459795752d8b', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W95HJK08BCRQSWFZBF4', '06FFW49WEE5N4PESF5G1G5JRHR', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'e114eff56fef8c92fe3b19df2b8b6e3c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ789MDB00EQAXTXBVJDXM', '06FGSQ8B7WDKZ174GJDGWW0GS0', '2026-06-28 06:14:17.763', '2026-06-28 06:14:17.763', NULL, '973169c6f426d9b4bcfd925ce0f36f32', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8B7WDKZ174GJDGWW0GS0', '06FGSQ8YSZA5YNP1JE8SJHXWBG', '2026-06-28 06:14:17.799', '2026-06-28 06:14:17.799', NULL, '60769ef20f951c01c5d20f512913b946', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', '06FB2ACSDBDZARV3NNGYD9NYYR', '2026-06-28 13:28:39.768', '2026-06-28 13:28:39.768', NULL, 'd5a7084fa717846475fad88d8f30febd', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', '06FB2ACSDBDZARV3NNGYD9NYYR', '2026-06-28 13:28:39.808', '2026-06-28 13:28:39.808', NULL, '79027c30d19f6f7d43c3068fd80ba8ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', '06FGX26B0NAC9WRJMVB0QE6CV8', '2026-06-28 14:01:13.773', '2026-06-28 14:01:13.773', NULL, '68cfce9178bbb310b87c76a908781c76', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2ETJQP0CT6X7W3CWZ6NS9G', '2026-06-10 11:12:20', '2026-06-28 15:29:40.913', '2026-06-28 15:29:40.913', '16d8a936aa3083cd16ca25a57a2e1b86', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', '06FB2ERREMEEF26KKHGNZBWW64', '2026-06-28 15:29:44.904', '2026-06-28 15:29:44.904', NULL, '545d2ce4f863fd5bb2eee240b696be0c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB234WP4Y6Q16A0HFW8BSXMG', '2026-06-10 11:12:11', '2026-06-28 15:29:57.491', NULL, '6e43179e6b211be30766ca56f5ed5885', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2AD3HPR3HXSVASVZEX8PK0', '2026-06-28 15:30:00.399', '2026-06-28 15:30:00.399', NULL, 'e0961be9550747530f530a7fd300583f', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FGX26B0NAC9WRJMVB0QE6CV8', '2026-06-28 15:30:03.803', '2026-06-28 15:30:03.803', NULL, 'fa47a4a4aff706215efa3a449681d18f', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-28 15:30:10.567', '2026-06-28 15:30:10.567', NULL, '52cd02f3a176cd078db479ef850cbcf4', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', '06FGX26B0NAC9WRJMVB0QE6CV8', '2026-06-28 17:50:13.767', '2026-06-28 17:50:13.767', NULL, '3e0ac1d814ac13a12b68714c7320075f', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-28 15:30:10.567', '2026-06-29 21:58:13.907', '2026-06-29 21:58:13.907', '3b7ec3277f971c3646a7587b06606335', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
File diff suppressed because it is too large Load Diff
+37
View File
@@ -302,3 +302,40 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'T-475', '2026-06-17 20:44:11.501', '2026-06-17 20:44:11.501', NULL, 'bb2b56ea9a76d967123238f588c70aba', 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 ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'T-476', '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, 'f75a0a8c00b0ec40c110c1169e42fe2c', 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 ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'T-477', '2026-06-22 07:13:19.958', '2026-06-22 07:13:19.958', NULL, '0314d96a690bdadc122ce0a1e9ed0f5c', 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 ('06FFKFYBZFKBX0WV574QJEH2BC', 'T-478', '2026-06-24 13:09:16.923', '2026-06-24 13:09:16.923', NULL, 'f1ea48c2cfa08915841d99eea06a9974', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', 'T-477', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '64605163f24e3846463f3afb4ed62fd1', 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 ('06FFW49W3V175EM4F8ZHC6JFM4', 'T-478', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '1c2e59e6220c0b288103c4a71a8282ca', 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 ('06FFW49W6GP535GR8XD1XEHYWG', 'T-479', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '2a6162f26c4c0b6e7bceb1f1a2ffe167', 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 ('06FFW49W95HJK08BCRQSWFZBF4', 'T-480', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '8a42c8c1fc2036d6ea73521266e87235', 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 ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'T-481', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, 'd1d616d020e6ea8661c1f5553e3001eb', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'T-482', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '557f92392db9b5c1e6b6840fe1293b7d', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', 'T-483', '2026-06-25 09:16:42', '2026-06-26 21:35:04.076', NULL, '2841b381bbb03e6a9cdb970280cd2207', 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 ('06FFW49W3V175EM4F8ZHC6JFM4', 'T-484', '2026-06-25 09:16:42', '2026-06-26 21:35:07.645', NULL, 'd90df1d5e2da44e81f60edb8c5fb2823', 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 ('06FGN13H42TP2MP3T27CTSCDRW', 'T-485', '2026-06-27 19:17:59.713', '2026-06-27 19:17:59.713', NULL, 'b345ba60d55cfdbc7c2e9bb9187ee171', 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 ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'T-486', '2026-06-27 22:43:31.629', '2026-06-27 22:43:31.629', NULL, 'cd344c35e0eca5673be26790aa8d292c', 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 ('06FGSQ789MDB00EQAXTXBVJDXM', 'T-487', '2026-06-28 06:13:51.821', '2026-06-28 06:13:51.821', NULL, '184f63035c0a014dc4fb863e512f8779', 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 ('06FGSQ8B7WDKZ174GJDGWW0GS0', 'T-488', '2026-06-28 06:14:00.768', '2026-06-28 06:14:00.768', NULL, '69e3283ee3c10bcd2ca5b5cbad6a8b1f', 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 ('06FGSQ8YSZA5YNP1JE8SJHXWBG', 'T-489', '2026-06-28 06:14:05.776', '2026-06-28 06:14:05.776', NULL, '0b37f24529eb3bb4f2b295dda3bb04ea', 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 ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'T-490', '2026-06-28 06:15:53.077', '2026-06-28 06:15:53.077', NULL, '586b7d8495ad019b436fddbf973c9e63', 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 ('06FGVCY4M0ZK72HZT2FSZF968W', 'T-491', '2026-06-28 10:08:32.933', '2026-06-28 10:08:32.933', NULL, '3895f296d80984ccd613ca9853a05eda', 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 ('06FGVDBD0F14FV5CDJQB063YSR', 'T-492', '2026-06-28 10:10:21.572', '2026-06-28 10:10:21.572', NULL, '341150811f14eb3f4486067ffb1197c4', 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 ('06FGVNCZS0JZP5Y7596GBV4RFC', 'T-493', '2026-06-28 10:45:31.721', '2026-06-28 10:45:31.721', NULL, 'eec40f4714242cf343035481684f2aba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'T-494', '2026-06-28 14:01:13.733', '2026-06-28 14:01:13.733', NULL, '37bf4baec69cb3d06072793cd3d60a46', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'T-495', '2026-06-28 17:50:02.366', '2026-06-28 17:50:02.366', NULL, '40dbd25d323a8192e8f65a6e1c2dd33d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'T-496', '2026-06-28 18:00:41.420', '2026-06-28 18:00:41.420', NULL, 'd2e472745c710eb00555b2b824d4e214', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZK6PWBRBK9J2XPJ55Y3630', 'T-497', '2026-06-28 19:55:10.439', '2026-06-28 19:55:10.439', NULL, '56f8bf5d0e9a52d9ce3c7a293b5f8972', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKEHPT1TMFMR0KYFGR087W', 'T-498', '2026-06-28 19:56:14.646', '2026-06-28 19:56:14.646', NULL, 'c62ee89f4aa203262fab74da8604777a', 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 ('06FGZKEHPSSHEG6ZBM0B4DMM6C', 'T-498', '2026-06-28 19:56:14.655', '2026-06-28 19:56:14.655', NULL, '1a47f2a97f13a7565080893758424fec', 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 ('06FGZKEHPW9GMXNACA38MK56HR', 'T-498', '2026-06-28 19:56:14.655', '2026-06-28 19:56:14.655', NULL, '2c606da2b11071cee3bfcee4855d4e66', 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 ('06FGZKJKW1721NKWY6XZG3KYB8', 'T-499', '2026-06-28 19:56:47.968', '2026-06-28 19:56:47.968', NULL, '135e7af3ddf32a676fcfadd150061dd6', 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 ('06FGZKJKW6EDH8TR45M86EWB5M', 'T-499', '2026-06-28 19:56:47.977', '2026-06-28 19:56:47.977', NULL, 'fc370ccee9cd94d0de9988f08963b8f1', 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 ('06FGZKJKW9P697RTQ68R3NKBR8', 'T-499', '2026-06-28 19:56:47.978', '2026-06-28 19:56:47.978', NULL, '99eb5554507cf5b29a158838f156d544', 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 ('06FGZKEHPW9GMXNACA38MK56HR', 'T-500', '2026-06-28 19:56:14.655', '2026-06-28 19:57:02.538', NULL, '6b529f95abd005b73e20b293dcba765c', 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 ('06FGZKEHPT1TMFMR0KYFGR087W', 'T-501', '2026-06-28 19:56:14.646', '2026-06-28 19:57:25.895', NULL, '4e1059be29e69c094168bb55e06704a4', 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 ('06FGZKJKW1721NKWY6XZG3KYB8', 'T-502', '2026-06-28 19:56:47.968', '2026-06-28 19:57:25.904', NULL, '2c08cf1dfc5c7ba16f756db7c80bc300', 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 ('06FGZKJKW6EDH8TR45M86EWB5M', 'T-503', '2026-06-28 19:56:47.977', '2026-06-28 19:57:25.905', NULL, '5177977a9215f17efa101e50b03ad8eb', 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 ('06FGZKJKW9P697RTQ68R3NKBR8', 'T-504', '2026-06-28 19:56:47.978', '2026-06-28 19:57:25.906', NULL, 'cb7f70a279326ff6e29a8c7bdeb78c0f', 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 ('06FGZKJKW9P697RTQ68R3NKBR8', 'T-504', '2026-06-28 19:56:47.978', '2026-06-28 19:57:25.906', NULL, 'cb7f70a279326ff6e29a8c7bdeb78c0f', 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 ('06FH621XBYJBGE1XPSFFK4YZGM', 'T-505', '2026-06-29 10:58:54.943', '2026-06-29 10:58:54.943', NULL, 'b402cc72b921d446a6e4f6c759d72d54', 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 ('06FHAX7FV5KWGZQ31617R63W94', 'T-506', '2026-06-29 22:16:52.953', '2026-06-29 22:16:52.953', NULL, 'fdeccc75d6a70fae57de814d11695473', 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);
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
# AGENTS.md for clide — Mistral Vibe operating as Claude Code peer
This file configures Mistral Vibe to operate in this repo with the same
effectiveness as Claude Code. It distills the CLAUDE.md guardrails and all
`.claude/skills/` into Vibe's instruction hierarchy, preserving "Claude punch"
in a hybrid workflow.
---
## Identity
You are operating in **clide** — a Flutter/Dart IDE for Claude Code CLI.
Your role: **peer to Claude Code**, not replacement. Maintain Claude's
behavioral standards, tool discipline, and architectural rigor.
**Primary directive:** Never lose "Claude punch" — the combination of
strict guardrails, CLI-first interaction, and parity between UI and CLI
that defines effective operation in this repo.
---
## Non-Negotiable Guardrails (from CLAUDE.md)
These are load-bearing. Violating any means the design is wrong, not the rule.
- **Flutter desktop is the host. No Electron, ever.**
- **Single process.** The Flutter app hosts everything in-process: IPC server,
subsystem handlers (pane, files, editor, git, pql), extensions.
- **CLI-first, not MCP.** Drive via `clide <subsystem> <verb>` Bash commands.
- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI
(`posix_openpt` + `posix_spawn`). `pql` (Go) handles vault queries.
- **Own the rendering stack.** PTY, markdown, graph, canvas — all clide-owned.
- **User/Claude parity (D-6).** Every CLI subcommand has a UI affordance,
and every UI action has a CLI equivalent.
- **pql: wrap, don't duplicate.** Clide wraps pql; never re-implements it.
- **Repo-is-the-workspace.** Git repo root is the workspace.
- **Decision discipline.** All architectural choices live in
`governance/decisions/<domain>.md` as `D-NNN` records. Open questions as
`Q-NNN` under `governance/questions/<domain>.md`. Rejected as `R-NNN`.
- **No pre-existing excuse.** Solo-dev repo — if `make test` is red, fix it
first, then your work. Surface blockers; don't push on top of broken state.
---
## Tool Discipline (from CLAUDE.md)
### Make targets are the entry points
| Purpose | Command | Never call directly |
|---------|---------|---------------------|
| Launch app | `make run` | `flutter run` |
| Static analysis | `make analyze` | `flutter analyze` |
| Format | `make format` | `dart format` |
| Fast test suite | `make test` | `flutter test` |
| Core subsystem tests | `make test-core` | underlying scripts |
| Accessibility tests | `make test-a11y` | underlying scripts |
| Integration tests | `make test-integration` | underlying scripts |
| Pre-push gate | `make push-check` | `ci/*` scripts |
| Setup hooks | `make hooks` | `cp .githooks/* .git/hooks/` |
| Clean | `make clean` | `rm -rf build/` |
### Shell hygiene
- **Working directory is repo root** — never `cd /path/to/clide` or `git -C`
- **One command per invocation** — no `&&`/`;` chaining
- Exception: `git commit -F` HEREDOC for multi-line messages
- Prefer Read/Edit/Grep tools over `cat`/`sed`/`grep` for file inspection
---
## Git Workflow (from git-commit skill)
### Commit message format: Conventional Commits 1.0
```
<type>(<scope>): <imperative subject> (<T-NNN>)
Optional body: explain WHY, not WHAT. Wrap at ~72 chars.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
```
**Type:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`
- Use `feat`/`fix` for user-visible behavior
- Use `chore` for bookkeeping (`chore(plan)` for pql ticket housekeeping)
- Append `!` after scope for breaking changes: `feat(ipc)!: ...`
**Scope:** Optional but preferred — subsystem: `settings`, `vim`, `pty`, `git`, `plan`
- Lower-case, no spaces
**Subject:** ≤ 72 characters INCLUDING prefix. No emojis. No "and".
**Ticket ref:** Trailing `(T-NNN)` when work has a ticket.
**Body:** Explain the *why*. The diff shows the *what*. Don't restate it.
- Hard cap: **60 words per bullet** for CHANGELOG entries
- No multi-paragraph bullets
- No sub-headers inside bullets
- No probe numbers, latency stats, or %-coverage deltas
### Logically-separated commits
1. **Read `git status` + `git diff` first** — never stage blind
2. **Group by concern, not location:**
- Bookkeeping: `.gitignore`, lockfiles, config
- Documentation: `README.md`, ADRs, design notes
- Tooling/skills: reusable, non-project-specific
- Project conventions: repo's own rules
- Feature/subsystem: one cohesive change
- Layer changes: app, sidecar CLI, daemon, IPC, pql wrapper, canvas, git panel
3. **Prefer many small focused commits over one large mixed one**
4. **Use `git add <specific paths>`** — NEVER `git add -A`, `git add .`, `git add -u`
5. **Verify between commits** with `git status`, `git diff --staged`, `git log -1`
### Changelog discipline
- **Every user-visible commit must touch CHANGELOG.md** under `## [Unreleased]`
- Use Keep a Changelog 1.1.0 format with sections: Added, Changed, Deprecated, Removed, Fixed, Security
- Entries: short imperative phrases describing user-facing impact
- **60 words hard cap per bullet** — verify with `make changelog-gate`
- What skips changelog: pure bookkeeping with no user-visible effect
### Safety rules (reinforced)
- **Never** `--no-verify`
- **Never** `--amend` unless user explicitly asks
- **Never** force-push to `main` or `master`
- **Always** check `git status` before staging, `git diff --staged` before committing
- Commit message via HEREDOC:
```bash
git commit -m "$(cat <<'EOF'
<message>
EOF
)"
```
### What NOT to commit
- `.env`, `*.env.local`
- `.claude/settings.local.json`
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/`, `app/.dart_tool/`
- SQLite index files: `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`
- Coverage/test output: `*.out`, `coverage.*`, `*.test`
- `.pql/changelog/` — auto-staged by pre-commit hook from pql DB
---
## pql — Vault Queries + Project Planning
`pql` indexes a vault into SQLite and exposes structural queries plus a
planning layer for decision records and tickets. One binary, two surfaces.
### Precondition
```bash
command -v pql
```
If absent, tell the user to install from
https://github.com/postmeridiem/pql/releases/latest. Don't install it
yourself. Don't fall back to grep unless the user explicitly asks.
### First touch: learn the vault
```bash
pql schema
```
Returns one row per frontmatter key with observed types and file counts.
Run once per session before writing queries.
---
### Surface 1: Vault queries
#### Subcommands
| Command | Purpose |
|---|---|
| `pql files [glob]` | List indexed files; optional glob filter |
| `pql tags [--sort count]` | Distinct tags with counts |
| `pql backlinks <path>` | Files linking TO a path |
| `pql outlinks <path>` | Links FROM a file |
| `pql meta <path>` | Frontmatter + tags + outlinks + headings for one file |
| `pql schema` | Typed frontmatter schema |
| `pql base <name>` | Execute an Obsidian .base file |
| `pql shell` | Interactive REPL (indexes once, then query per line) |
| `pql query "<DSL>"` | SQL-derived DSL for complex queries |
| `pql doctor` | Resolved vault/config/DB/index state |
#### DSL examples
```sql
SELECT name, fm.date WHERE fm.type = 'meeting' ORDER BY fm.date DESC LIMIT 10
SELECT path WHERE 'project' IN tags ORDER BY path
SELECT name, fm.prior_job WHERE fm.type = 'council-member' ORDER BY name
```
Use `--file q.pql` or `--stdin` for long queries. Don't interpolate vault
content into the command line.
#### Query cookbook
- **Files in folder**`pql files 'sessions/*'`
- **Top tags**`pql tags --sort count --limit 20`
- **What links to X?**`pql backlinks members/vaasa/persona.md`
- **Date range**`pql query "SELECT name, fm.date WHERE fm.date BETWEEN '2024-01-01' AND '2024-12-31'"`
- **Run a Base**`pql base council-sessions`
- **Inspect one file**`pql meta members/vaasa/persona.md --pretty`
---
### Surface 2: Planning (decisions + tickets)
Planning state lives in `<vault>/.pql/pql.db` (user-authored state, not a
cache). Decision records come from the DQR tree — `governance/{decisions,
questions,rejected}/<domain>.md` by default (D-21), configurable via
`dqr_dir` in `.pql/config.yaml` or the `PQL_DQR_DIR` env var (env > file >
default); a legacy flat `decisions/` is auto-detected as a fallback.
Tickets are SQLite-native.
#### Decision subcommands
| Command | Purpose |
|---|---|
| `pql decisions sync [--no-style]` | Parse the DQR tree → upsert into pql.db; surfaces style warnings (filename, subdir-type, domain pairing/conflicts) unless `--no-style` |
| `pql decisions validate [--no-style]` | Dry-run parse; structural errors exit non-zero, style issues warn (suppress with `--no-style`) |
| `pql decisions claim <D\|Q\|R> <domain> "title"` | Print next available ID |
| `pql decisions list [--type X] [--domain X] [--status X]` | List decisions |
| `pql decisions show <id> [--with-refs] [--with-tickets]` | Show with joins |
| `pql decisions coverage` | Confirmed decisions without tickets |
| `pql decisions refs <id>` | Cross-references involving a decision |
Always `pql decisions sync` before querying if decisions/*.md may have changed.
#### Ticket subcommands
| Command | Purpose |
|---|---|
| `pql ticket new <type> "title" [--parent T-NNN] [--decision D-NNN] [--priority P] [--id-only]` | Create (emits T-NNN; `--parent` files it under an epic/story in one step; `--id-only` prints the bare id for tree-creation scripts) |
| `pql ticket list [--status S] [--team T] [--assigned A] [--label L] [--under T-NNN] [--leaf] [--unblocked]` | List with filters. `--under` = recursive descendants of a ticket; `--leaf` = no children; `--unblocked` = blockers all reached a terminal status |
| `pql ticket show <id[,id,...]> [--with-context] [--with-blockers] [--with-children] [--tree] [--depth N]` | Show one or more (comma-batch → array of show-trees). `--with-children` = direct children; `--tree` = nested descendant subtree + direct parent (cap with `--depth N`) |
| `pql ticket status <id> <new-status> [--force]` | Change status. Closing (terminal status) is blocked while the ticket has open children; `--force` cascades that status to all not-yet-closed descendants and lists them |
| `pql ticket statuslist` | List the configured status vocabulary (name, label, class, order, is_default, is_terminal) — what a UI reads to render columns |
| `pql ticket relabel <id\|record_id> [--new-label T-NNN] [--fix-prose]` | Reassign a ticket's friendly T-NNN label (reconcile a duplicate-label collision). Identity (record_id) and the structural graph are untouched; only the label moves. `--fix-prose` rewrites stale T-NNN mentions in DQR markdown |
| `pql ticket assign <id> <agent>` | Set assignee |
| `pql ticket setparent <id[,id,...]> <parent-id \| none>` | Set (or clear with `none`) a ticket's **parent** — the hierarchy link (epic→story→task). Positional, not a flag. This is the parent/child relationship, distinct from blockers |
| `pql ticket append <id> <text\|--file\|--stdin>` | Append to the description (blank-line separated); never round-trips existing text |
| `pql ticket block <id> --by <other>` | Add a **blocker** (a dependency: <id> can't start until <other> is done) — NOT a parent/child link; use `setparent` or `new --parent` for hierarchy |
| `pql ticket unblock <id> --from <other>` | Remove blocker |
| `pql ticket team <id> <team>` | Set team |
| `pql ticket label <id> add\|rm <label>` | Manage labels |
| `pql ticket board [--team T]` | Kanban board view |
| `pql ticket refine list` | Tickets with empty descriptions, status-priority-sorted |
| `pql ticket refine next [--skip N]` | Head of the unrefined queue with full show-tree + remaining count |
| `pql ticket refine write <id> <json\|--file\|--stdin>` | Patch writable fields (title, description, priority, type) |
Ticket types: initiative, epic, story, task, bug.
The `id` you type and see (T-NNN) is a friendly label backed by a stable
underwater `record_id` (also in output); two clones never collide on identity,
and a duplicate label is fixed with `pql ticket relabel` (D-26).
Statuses are a per-vault vocabulary (`ticket_statuses` in `.pql/config.yaml`),
defaulting to: backlog, ready, in_progress, review, done, cancelled. Each status
has a class — initial, active, review, terminal — that the engine reasons about.
Run `pql ticket statuslist` to discover the live set. Any status can transition
to any other — pql does not enforce a state machine — except that a ticket
cannot reach a terminal status while it has open children (use `--force` to
cascade the close down the subtree).
#### Plan subcommands
| Command | Purpose |
|---|---|
| `pql plan status` | Dashboard: decision counts, open Qs, ticket summary, coverage gaps |
| `pql plan whatsnext` | Next ticket to work on (active work, then the "ready" lane) with full context bundle |
| `pql plan review` | Next ticket awaiting review with full context bundle |
| `pql plan export [--stage]` | Append changed planning rows to `.pql/changelog/<table>/<YYYY-MM>.sql` (the git-tracked log of record); `--stage` also `git add`s them. Normally a no-op — mutations already write through |
| `pql plan import [--legacy FILE]` | Replay `.pql/changelog/` into `pql.db` (or one-time `--legacy pql-plan.json` migration from the pre-D-15 snapshot) |
| `pql plan rebuild` | Drop replicated tables and replay `.pql/changelog/` from scratch. Warns on stderr (`changelog.ticket_id_collision`) + lists `collisions` in the result if one ticket id was filed twice across clones |
#### Versioning planning state
`pql.db` is gitignored — the durable, git-tracked artifact is
`.pql/changelog/` (D-15/D-16). Ticket mutations **write through** to the
changelog synchronously, so it is always current; you never have to
remember to "export". The hooks installed by `pql init` do the rest:
- `pre-commit` stages `.pql/changelog/` so it lands in the same commit as
the change that produced it.
- `post-merge` replays incoming changelog edits (`pql plan import`) and
re-syncs decisions from their markdown.
- `post-checkout` / `post-rewrite` rebuild `pql.db` from the changelog.
On a fresh clone, `pql plan import` (run automatically on first open)
replays the changelog into a new `pql.db`. There is **no** `pql-plan.json`
snapshot — that artifact is retired; `pql plan export` is now only a
manual catch-up/reconcile.
A ticket mutation (create / status transition / any write) leaves
`.pql/changelog/` dirty by design — the `pre-commit` hook stages it onto
the next `git commit`. This is expected, not a problem to flag. Don't
narrate "the ticket won't persist until committed" on every edit; either
fold the bookkeeping into a commit or trust the normal commit flow.
#### Planning cookbook
- **Sync and list confirmed**`pql decisions sync && pql decisions list --type confirmed`
- **Show with refs**`pql decisions show D-5 --with-refs --pretty`
- **Read full body**`pql decisions read D-5`
- **Create ticket**`pql ticket new task "implement X" --decision D-5`
- **Create ticket, capture id for a script**`id=$(pql ticket new task "implement X" --id-only)` — prints just `T-NNN`
- **File a ticket under an epic**`pql ticket new bug "fix X" --parent T-276` (one step), or reparent an existing one → `pql ticket setparent T-9 T-276` (clear with `none`). Parent = hierarchy; use `block` only for blocking dependencies
- **Batch close**`pql ticket status T-1,T-2,T-3 done`
- **Full context**`pql ticket show T-5 --with-context --pretty`
- **Batch show**`pql ticket show T-1,T-2,T-3 --pretty`
- **Refine next ticket**`pql ticket refine next --pretty`, then `pql ticket refine write T-N '{"description":"..."}'`
- **Append a note**`pql ticket append T-5 "benchmarked; TTL now 5m"` — blank-line separated, never overwrites; use `--file note.md` or `--stdin` for longer content
- **Subtree of an epic**`pql ticket show T-2 --tree --pretty` — nested `subtree` + direct parent in `ancestors`; add `--depth N` to cap levels
- **Ready leaf work under an epic**`pql ticket list --under T-2 --leaf --unblocked` — leaf tickets beneath T-2 whose blockers have all reached a terminal status; the batch complement to `plan whatsnext`
- **What's next?**`pql plan whatsnext --pretty`
- **Review queue**`pql plan review --pretty`
- **Coverage gaps**`pql decisions coverage`
- **Dashboard**`pql plan status --pretty`
- **Force a changelog catch-up**`pql plan export` (normally a no-op; mutations already write through to `.pql/changelog/`)
---
### Output contract (both surfaces)
- **stdout:** JSON array (default); `--jsonl` for one object/line; `--pretty`; `--limit N`.
- **stderr:** JSON diagnostics `{"level":"...","code":"pql.<phase>.<kind>","msg":"..."}`.
- **Exit codes:**
- `0` — success, including zero matches (empty `[]` — say "no matches", not "failed")
- `64` — bad flag
- `65` — parse/compile error (pass stderr back)
- `66` — vault/config not found
- `69` — unavailable
- `70` — internal error
### Anti-patterns
- Don't pipe to `jq` for simple projections — use `--limit`, `--pretty`, `--jsonl`.
- Don't chain `pql files` + `pql meta` — one `pql query` with WHERE.
- Don't parse errors — pass stderr diagnostics back directly.
- Don't forget `pql decisions sync` before querying decisions.
- Don't try to install or upgrade pql — instruct the user if missing.
### When NOT to use
- **Body text search**`grep`/`rg`.
- **Reading file contents**`Read` tool.
- **Code structure** → tree-sitter / LSP.
- **Modifying vault files**`Write`/`Edit`. pql doesn't write to vault content.
---
## Driving clide UI via CLI (from clide skill)
**Core principle: Every UI action has a CLI equivalent (D-6).**
Discover the live surface; don't hard-code it.
### Discover capabilities
```bash
clide capabilities
```
Returns JSON: every registered command, split into `subsystem` + `verb`,
with argument schema. **This is authoritative** — re-run it, don't trust
remembered lists.
### Slots (layout areas)
- `sidebar` — left
- `workspace` — center (where Claude lives)
- `context` — right
- `statusbar` — bottom
### Observe state
```bash
# One-shot orientation
clide status
# Narrower snapshots
clide pane list
clide editor active
clide git status
```
### Drive UI
```bash
# Open a doc in a GUI reader
clide ui open <reader> <ref>
# readers: tickets, decisions, markdown, diff
# examples:
clide ui open tickets T-123
clide ui open decisions D-456
clide ui open diff lib/src/foo.dart
clide ui open markdown docs/bar.md
# Show diff and scroll to file
clide ui diff lib/src/foo.dart
# Raise a toast
clide ui toast "message" --severity success|warning|error|info
# General: clide <subsystem> <verb> [args]
clide files list
clide editor open <path>
clide pane focus <pane-id>
```
**Convention:** If a drive verb has no live GUI, return toolError
("no live UI to drive"), not hang. Exit code conveys ok/usage/tool error.
JSON on stdout.
### You only see what flows through clide
Your own non-`clide` shell work (plain file reads, `make test`, `git`) is
outside clide's view by design (D-83). Run it **through** `clide ...` if
you want clide to observe it.
---
## Testmode (from testmode skill)
`ClideTestApp` is a lightweight Flutter app for integration testing.
Catches regressions unit tests cannot: missing binaries, broken subprocess
wiring, IPC dispatch failures, extension activation order, theme parse errors.
### Running
```bash
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_TIMEOUT=120
```
Results: stdout + `/tmp/clide-testmode.log`
Last line is machine-readable JSON: `{"passed":N,"failed":M,"total":N+M,"failures":[...]}`
### When to run
| Changed area | Category | Why |
|-------------|----------|-----|
| Toolchain, PATH, ptyc | `toolchain` | Binary resolution + exec |
| IPC envelope, dispatcher | `ipc` | Round-trip + error contract |
| Extension manifest, activate | `extensions` | Register + activate lifecycle |
| Theme YAML, loader | `extensions` | Theme parse is in this category |
| Platform config | `all` | Full rebuild validates everything |
| Any doubt | `all` | ~30s, cheap insurance |
### Interpreting output
- `[testmode] exec | ... | OK` — subprocess ran, exit 0 or 1
- `[testmode] PASS | ...` — assertion passed
- `[testmode] FAIL | ...` — assertion failed
- `[testmode] exec | ... | EXCEPTION` — binary not found or not executable
- `[testmode] exec | ... | TIMEOUT` — subprocess hung
If no `[testmode]` lines appear, the testmode gate didn't fire — verify
`CLIDE_TESTMODE` is set to a non-empty string.
### Adding tests
All test logic in `lib/test_app.dart`. Pattern:
```dart
await _testExec('label', binary, ['args'], workDir);
// or
_addResult('label', boolCondition, 'detail string');
```
---
## Repo Layout
```
lib/
main.dart # Flutter entry point
app.dart # Root layout, workspace, panels
clide.dart # Barrel: shared types
src/ # Core subsystems (IPC, PTY, git, files, pql, panes)
kernel/ # Kernel services (theme, i18n, settings, panels)
builtin/ # Built-in extensions
widgets/ # Custom widget primitives
extension/ # Extension contract and registration
lua/ # Lua runtime support
test/ # All tests (core + widgets + goldens + a11y)
assets/ # Fonts, themes, grammars, licenses, logo
linux/, macos/, web/ # Flutter platform directories
native/ # Vendored native libs (tree-sitter, dugite)
governance/ # D/Q/R records
docs/ # Design docs, wireframes
legacy/ # Python Textual clide v1.2 (frozen)
```
---
## Dependency & Supply Chain Discipline
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party needs justification
- **Exact-pinned in pubspec.yaml** — no caret ranges
- **Advisories reviewed** before every bump
- **pubspec.lock committed**
- **Document every bundled dependency** in `assets/licenses.yaml`:
- name, kind, version, homepage, license, purpose
- Adding a dep: two-step commit — artefact AND `licenses.yaml` entry
- Native deps (dugite, libtree-sitter): vendored in `native/`, pinned by SHA
---
## Pre-push Gate
```bash
make push-check
```
Runs: decisions validation + core tests + fast suite + a11y + coverage + changelog
**Never bypass.** If it fails, fix the underlying issue.
---
## Session Setup
One-time setup on fresh clone:
```bash
make hooks && flutter pub get
pql init # wires up pql skill + perms
```
---
## Mistral-Specific Notes
### What to preserve from Claude
- CLI-first interaction model
- Strict guardrails enforcement
- Logically-separated commits
- Changelog discipline (60-word cap)
- pql as single source of truth for decisions/tickets
- UI/CLI parity
- Testmode for integration validation
### What to adapt
- **Tool names:** Claude's `Bash(...)` → my `bash` tool
- **Agent spawning:** Claude's `Agent{}` → my `task` tool for subagents
- **Permissions:** `.claude/settings.json` allow/deny → my system prompt + your instructions
### When in doubt
Ask: "What would Claude do?" Then do that. The AGENTS.md is the bridge,
not a replacement.
---
## Quick Reference Card
| Task | Command |
|------|---------|
| Discover clide commands | `clide capabilities` |
| See current UI state | `clide status` |
| Open ticket in UI | `clide ui open tickets T-NNN` |
| Toast notification | `clide ui toast "msg" --severity info` |
| List actionable tickets | `pql ticket list --under <id> --leaf --unblocked --status backlog --pretty` |
| Sync decisions | `pql decisions sync` |
| Run integration tests | `make run-testmode` |
| Fast test suite | `make test` |
| Full pre-push | `make push-check` |
| Commit with message | `git commit -m "$(cat <<'EOF'\n<message>\nEOF\n)"` |
| Verify changelog | `make changelog-gate` |
---
*Generated for Mistral Vibe. Preserves Claude Code punch for clide repo.*
+118
View File
@@ -22,6 +22,115 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
## [2.9.0] — 2026-06-30
### Added
- **Graph cards.** `clide draw --file graph.json` (template `graph`) renders a
nodes/edges graph in the conversation as a circular layout — labelled nodes,
lines between them. Honest error on a duplicate id or an edge to an unknown
node. (T-321)
- **Piped `--stdin` payloads.** `cat icons.json | clide icon show --stdin` (and
`image show`) accept a JSON payload on stdin — the ergonomic peer of `--file`
for structured commands. (T-315)
- **Before/after compare cards.** `clide draw --file compare.json` (template
`compare`) renders two or more images side by side, each with its own
label/description and tap-to-zoom; images aspect-fit so differing shapes
don't distort. (T-319)
- **Phosphor glyph cards.** `clide icon show gear folder` (or `--file` entries
with label/description/color) renders glyphs in the conversation at a hero
size plus a real-UI-size strip (1048), for previewing and comparing icons.
Resolves by name or 0xNNNN codepoint; honest error on an unknown glyph. (T-313)
- **Annotated image cards.** `clide image show --file meta.json` attaches a
title/label and a longer description to an image card (alongside the existing
one-line caption); the bare `image show <path> --caption` form is unchanged.
Visual marker overlays remain a follow-up. (T-316)
- **D2 diagram cards.** `clide draw --file diagram.d2` compiles a d2 diagram to
SVG (via the `d2` binary) and renders it in the conversation; `template:"d2"`
with inline source works too, and a `.svg` file renders directly. Honest error
if d2 isn't installed or the source doesn't compile. (T-494, D-103)
- **Tool path resolution + settings.** clide resolves supporter binaries (claude,
d2) via an explicit per-tool path, else PATH and the common install dirs — now
including Homebrew-on-Linux. A Tools settings category edits the paths and
re-detects; a broken path shows in Problems. (T-495, D-104)
- **Check for updates (About box).** Help → About has a manual "Check for
updates" button that compares your version to the latest GitHub release and
links to the notes — explicit and user-initiated, no background polling (the
first and only outbound call clide makes, on your action). (T-47)
- **Live-sync markdown read-mirror.** Editing a `.md` in the editor auto-opens a
read-only preview in the context panel that mirrors the buffer and re-renders
as you type; non-renderable files get no auto-viewer. (T-36, D-50)
- **New project flow.** A "New project…" action in the welcome view creates +
opens a project, then prompts for the Claude account to bind it to — the
per-repo account roadblock fires only for freshly-created projects. (T-488,
story T-486)
- **Initialize a non-repo folder.** Opening a folder that isn't a git repo now
offers to initialize it as a clide project (`git init` + scaffold + the
account roadblock) instead of dead-ending. Also `clide project init`. (T-489)
- **`clide project new <name> [--dir <parent>]`.** Create a new clide project —
a fresh dir, `git init`, and a minimal scaffold. `--dir` defaults to the
current workspace's parent. (T-487, story T-486)
- **Claude account login pane.** `account login` (and the UI add/re-login
affordances) open a modal terminal running `CLAUDE_CONFIG_DIR=<dir> claude
login`; the CLI owns the OAuth flow, credentials land in that account's dir.
(T-485, epic T-476)
- **Claude pane account badge.** The pane header shows which account this repo
is bound to (colour-tinted per account); tap to switch. Hidden when no
accounts are registered. (T-481, epic T-476)
- **Settings → Claude → Accounts.** A registry list (sign-in status, dir,
re-login / remove, add) plus a per-workspace picker that binds this repo to an
account (or Default); switching respawns the pane onto it. (T-482, epic T-476)
- **Per-repo Claude accounts — `clide claude account` verbs.** Manage named
Claude config dirs and bind one per workspace: `add`/`list`/`set`/`unset`/
`remove [--purge]`. `set`/`unset` respawn the workspace's Claude pane(s) onto
the bound account (resuming the conversation under its `CLAUDE_CONFIG_DIR`).
(T-480, epic T-476)
- **`clide instances` / `clide instance` CLI verbs.** `instances` lists every
live clide on the machine with its version, pid, workspace, and socket path
(probing the runtime dir); `instance` reports the one you're connected to — so
you can find and target a specific instance via `CLIDE_SOCK`. (T-247)
- **Summer Night theme + high-contrast sibling.** The legacy v1.2 palette is
fleshed out to full token + syntax coverage and ships alongside a
contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance.
(T-478)
### Changed
- **Fresh Claude sessions are nudged to use the bundled skills.** New tabs and
post-`/clear` respawns get a one-line prompt to load the `pql` + `clide`
skills from the first turn; resumed/forked sessions are left alone. (T-490)
- **Claude meta sidebar facelift.** The Activity, Team, and Config tabs render
their sections as elevated cards with small-caps headers, matching the
settings overlay's card design. (T-158)
- **Bundled themes and Tier-0 i18n namespaces resolve from one canonical list
each.** The app, testmode harness, and contrast/i18n gates iterate the shared
lists instead of drifting copies; a new theme or catalog is validated
automatically, and the i18n gate now checks every catalog for en/nl parity.
(T-371)
### Fixed
- **No more i18n "missing key" log spam for tool names.** Proper-name tools
(Bash, ScheduleWakeup, MCP tools, …) intentionally have no catalog entry and
fall back to the raw name; the conversation pane no longer logs a warning for
each. (T-493)
- **`clide` CLI honors `CLIDE_SOCK`.** The shell client now connects to the
socket named by `CLIDE_SOCK` when set — an explicit target (e.g. a spawned
agent pinning its parent instance) that beats workspace auto-discovery — and
fails loudly if that socket is dead instead of silently driving a different
instance. (T-247)
- **Orphaned IPC sockets are swept on startup.** The app probes the runtime
socket dir on launch and unlinks dead `*.sock` nodes left by crashed
instances (live instances are left untouched), so the dir no longer
accumulates stale sockets. (T-247)
- **Default window opens larger (1600×900) on Linux.** 720p was short enough
that the welcome screen's version/theme footer overlapped the tips card;
the taller default clears it, matching the macOS default. (T-477)
- **Editor split collapses when the last buffer closes.** Closing the final
editor buffer left the top split orphaned over the Claude pane; the daemon
now emits the buffer-cleared event it was suppressing, so the split drops
out and the primary pane fills the column. (T-459)
## [2.8.0] — 2026-06-19
### Added
@@ -38,6 +147,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
locale under `assets/i18n/<locale>/`. A new language is a drop-in folder;
en_US behaviour is unchanged. (T-462)
## [2.8.1] — 2026-06-22
### Fixed
- **Localized the remaining Claude-pane strings.** Running-indicator verbs, the
primary/secondary session titles and banner role, tool-card titles, the
step/edit/agent counters, and the folded-activity ticker now resolve through
the catalog — completing i18n coverage of the conversation surface. (T-462)
## [2.7.1] — 2026-06-18
### Changed
+11 -2
View File
@@ -169,8 +169,7 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
.PHONY: build
build: gen-build-info clide-cli ## flutter build for the current OS (incl. the C CLI client).
flutter build $(FLUTTER_OS)
build: clide-cli build-$(FLUTTER_OS) ## flutter build for the current OS (via build-<os>) + bundle the C CLI client.
@install -m 755 $(CLIDE_CLI_BIN) $(CLI_BUNDLE_DEST)
@echo "==> bundled C client at $(CLI_BUNDLE_DEST)"
@@ -186,6 +185,10 @@ build-macos: gen-build-info ## flutter build macos (desktop bundle).
build-windows: gen-build-info ## flutter build windows (desktop bundle).
flutter build windows
.PHONY: release
release: ## Finalize a release: verify version/changelog/tree, run the gate, tag vX.Y.Z. Run after the `release vX.Y.Z` commit.
ci/release.sh
# -- install / uninstall -----------------------------------------------------
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
@@ -261,6 +264,8 @@ else ifeq ($(FLUTTER_OS),macos)
endif
# -- dugite-native (bundled git) ------------------------------------------
# Security tracking (D-59): run `make dugite-check` quarterly, or on a git CVE,
# to compare this pin against the latest upstream release. T-88 is the calendar.
DUGITE_VERSION := v2.53.0-3
DUGITE_COMMIT := f49d009
@@ -327,6 +332,10 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
security: ## Supply-chain gate — osv-scanner over pubspec.lock (CI PR-merge pipeline; run locally on demand). Fails on a known advisory.
ci/osv_scan.sh
.PHONY: dugite-check
dugite-check: ## Track dugite-native (bundled git) upstream releases for security drift (T-88 / D-59). Run quarterly, or on a git CVE. Informational, not a gate.
ci/check_dugite_version.sh
# -- pre-push gate --------------------------------------------------------
.PHONY: decisions-validate
+1 -1
View File
@@ -1,6 +1,6 @@
# clide
An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux, macOS and Windows.
## Architecture
+49 -1
View File
@@ -6,13 +6,37 @@
"status.primary-exited": { "translation": "session exited — restart clide to retry" },
"banner.title": { "translation": "Claude" },
"banner.warmingUp": { "translation": "Warming up — your conversation will appear here." },
"banner.role.primary": { "translation": "primary" },
"banner.role.secondary": { "translation": "session {index}" },
"composer.hint": { "translation": "Message Claude… (Enter to send · Shift+Enter for newline)" },
"composer.stop": { "translation": "Stop ⎋" },
"composer.stop.hint": { "translation": "Interrupt the running turn (Escape)" },
"composer.removeAttachment": { "translation": "Remove {name}" },
"pane.title.primary": { "translation": "claude — primary" },
"pane.title.secondary": { "translation": "claude — secondary {index}" },
"pane.starting": { "translation": "starting…" },
"pane.modeBadge.semantics": { "translation": "permission mode: {mode}" },
"running.semantics": { "translation": "Claude is running" },
"running.verb.pondering": { "translation": "Pondering" },
"running.verb.conjuring": { "translation": "Conjuring" },
"running.verb.brewing": { "translation": "Brewing" },
"running.verb.tinkering": { "translation": "Tinkering" },
"running.verb.noodling": { "translation": "Noodling" },
"running.verb.percolating": { "translation": "Percolating" },
"running.verb.computing": { "translation": "Computing" },
"running.verb.wrangling": { "translation": "Wrangling" },
"running.verb.untangling": { "translation": "Untangling" },
"running.verb.synthesizing": { "translation": "Synthesizing" },
"running.verb.cogitating": { "translation": "Cogitating" },
"running.verb.whirring": { "translation": "Whirring" },
"running.verb.mincing": { "translation": "Mincing" },
"running.verb.boiling": { "translation": "Boiling" },
"running.verb.humming": { "translation": "Humming" },
"running.verb.buzzing": { "translation": "Buzzing" },
"running.verb.magicking": { "translation": "Magicking" },
"running.verb.cliding": { "translation": "Cliding" },
"running.verb.zooming": { "translation": "Zooming" },
"running.verb.bouncing": { "translation": "Bouncing" },
"conversation.empty": { "translation": "Waiting for Claude…" },
"conversation.label.you": { "translation": "you" },
"conversation.label.claude": { "translation": "claude" },
@@ -23,6 +47,9 @@
"conversation.label.thinking": { "translation": "thinking" },
"conversation.label.agentThinking": { "translation": "agent thinking" },
"conversation.label.image": { "translation": "image" },
"conversation.label.icon": { "translation": "icons" },
"conversation.label.drawing": { "translation": "drawing" },
"conversation.draw.viewSource": { "translation": "view d2 source" },
"conversation.label.agentRun": { "translation": "agent run" },
"conversation.label.workflow": { "translation": "workflow" },
"conversation.label.error": { "translation": "error" },
@@ -39,6 +66,12 @@
"conversation.bashTail.label": { "translation": "live tail" },
"conversation.cluster.activity": { "translation": "Activity" },
"conversation.cluster.edits": { "translation": "Edits" },
"conversation.counter.step": { "translation": "1 step" },
"conversation.counter.steps": { "translation": "{count} steps" },
"conversation.counter.edit": { "translation": "1 edit" },
"conversation.counter.edits": { "translation": "{count} edits" },
"conversation.counter.starting": { "translation": "starting" },
"conversation.counter.agents": { "translation": "{done}/{total} agents" },
"prompt.permission.allow": { "translation": "1. Allow" },
"prompt.permission.allowRemember": { "translation": "2. Allow & don't ask again" },
"prompt.permission.deny": { "translation": "{n}. Deny" },
@@ -62,6 +95,16 @@
"tool.edit.before": { "translation": "— before" },
"tool.edit.after": { "translation": "+ after" },
"tool.bash.background": { "translation": "background" },
"tool.name.Read": { "translation": "Read" },
"tool.name.Edit": { "translation": "Edit" },
"tool.name.MultiEdit": { "translation": "MultiEdit" },
"tool.name.Write": { "translation": "Write" },
"tool.name.NotebookEdit": { "translation": "NotebookEdit" },
"tool.name.WebFetch": { "translation": "WebFetch" },
"tool.name.WebSearch": { "translation": "WebSearch" },
"tool.name.Task": { "translation": "Task" },
"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)" },
"permissionBadge.tooltip": { "translation": "Permission mode: {mode}. Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions." },
@@ -197,5 +240,10 @@
"settings.claude.effort.label": { "translation": "Effort" },
"settings.claude.effort.help": { "translation": "Reasoning effort for new sessions (applied via --effort at spawn)." },
"settings.claude.permissionMode.label": { "translation": "Permission mode" },
"settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." }
"settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." },
"settings.claude.account.label": { "translation": "Account" },
"settings.claude.account.registry.label": { "translation": "Accounts" },
"settings.claude.account.registry.help": { "translation": "Registered Claude accounts (each a separate config dir + login)." },
"settings.claude.account.workspace.label": { "translation": "Account for this workspace" },
"settings.claude.account.workspace.help": { "translation": "Which Claude account this repo runs under; Default uses the system login." }
}
+5
View File
@@ -6,6 +6,11 @@
"about.commit": { "translation": "Commit" },
"about.built": { "translation": "Built" },
"about.repository": { "translation": "Repository" },
"about.checkUpdates": { "translation": "Check for updates" },
"about.checking": { "translation": "Checking…" },
"about.upToDate": { "translation": "You're on the latest version." },
"about.updateAvailable": { "translation": "clide {version} is available — release notes" },
"about.updateFailed": { "translation": "Couldn't check for updates" },
"licenses.heading": { "translation": "Bundled dependencies" },
"licenses.unavailable": { "translation": "Licenses unavailable." },
"licenses.loading": { "translation": "Loading…" },
@@ -0,0 +1,11 @@
{
"command.detect": { "translation": "Re-detect tool paths" },
"settings.title": { "translation": "Tools" },
"settings.section.binaries": { "translation": "Supporter binaries" },
"settings.field.claude.label": { "translation": "Claude CLI" },
"settings.field.claude.help": { "translation": "Absolute path to the claude binary; blank to auto-resolve." },
"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." }
}
+8 -1
View File
@@ -14,6 +14,11 @@
"tips.findInFiles": { "translation": "Find in files" },
"tips.focusMode": { "translation": "Focus mode" },
"action.openFolder": { "translation": "Open folder…" },
"action.newProject": { "translation": "New project…" },
"dialog.newProject.title": { "translation": "New project" },
"dialog.newProject.body": { "translation": "Creates a git repo + a CLAUDE.md, then opens it." },
"button.create": { "translation": "Create" },
"button.creating": { "translation": "Creating…" },
"recent.empty": { "translation": "No recent projects." },
"sticky.label": { "translation": "always open this project on launch" },
"sticky.tooltip": { "translation": "Always open this project on launch" },
@@ -30,6 +35,8 @@
"button.opening": { "translation": "Opening…" },
"button.ok": { "translation": "OK" },
"dialog.notRepo.title": { "translation": "No git repo found" },
"dialog.notRepo.body": { "translation": "A clide project root requires a git repository." },
"dialog.notRepo.body": { "translation": "A clide project needs a git repository. Initialize this folder as one?" },
"dialog.notRepo.initialize": { "translation": "Initialize project" },
"button.initializing": { "translation": "Initializing…" },
"command.workspace.open-project": { "translation": "Workspace: Open project…" }
}
+49 -1
View File
@@ -6,13 +6,37 @@
"status.primary-exited": { "translation": "sessie beëindigd — herstart clide om opnieuw te proberen" },
"banner.title": { "translation": "Claude" },
"banner.warmingUp": { "translation": "Opstarten — je gesprek verschijnt hier." },
"banner.role.primary": { "translation": "primair" },
"banner.role.secondary": { "translation": "sessie {index}" },
"composer.hint": { "translation": "Bericht aan Claude… (Enter om te verzenden · Shift+Enter voor een nieuwe regel)" },
"composer.stop": { "translation": "Stop ⎋" },
"composer.stop.hint": { "translation": "Onderbreek de lopende beurt (Escape)" },
"composer.removeAttachment": { "translation": "{name} verwijderen" },
"pane.title.primary": { "translation": "claude — primair" },
"pane.title.secondary": { "translation": "claude — secundair {index}" },
"pane.starting": { "translation": "starten…" },
"pane.modeBadge.semantics": { "translation": "permissiemodus: {mode}" },
"running.semantics": { "translation": "Claude is bezig" },
"running.verb.pondering": { "translation": "Peinzen" },
"running.verb.conjuring": { "translation": "Toveren" },
"running.verb.brewing": { "translation": "Brouwen" },
"running.verb.tinkering": { "translation": "Knutselen" },
"running.verb.noodling": { "translation": "Prutsen" },
"running.verb.percolating": { "translation": "Pruttelen" },
"running.verb.computing": { "translation": "Rekenen" },
"running.verb.wrangling": { "translation": "Worstelen" },
"running.verb.untangling": { "translation": "Ontwarren" },
"running.verb.synthesizing": { "translation": "Synthetiseren" },
"running.verb.cogitating": { "translation": "Overpeinzen" },
"running.verb.whirring": { "translation": "Snorren" },
"running.verb.mincing": { "translation": "Hakken" },
"running.verb.boiling": { "translation": "Koken" },
"running.verb.humming": { "translation": "Neuriën" },
"running.verb.buzzing": { "translation": "Zoemen" },
"running.verb.magicking": { "translation": "Goochelen" },
"running.verb.cliding": { "translation": "Cliden" },
"running.verb.zooming": { "translation": "Zoeven" },
"running.verb.bouncing": { "translation": "Stuiteren" },
"conversation.empty": { "translation": "Wachten op Claude…" },
"conversation.label.you": { "translation": "jij" },
"conversation.label.claude": { "translation": "claude" },
@@ -23,6 +47,9 @@
"conversation.label.thinking": { "translation": "nadenken" },
"conversation.label.agentThinking": { "translation": "agent denkt na" },
"conversation.label.image": { "translation": "afbeelding" },
"conversation.label.icon": { "translation": "iconen" },
"conversation.label.drawing": { "translation": "tekening" },
"conversation.draw.viewSource": { "translation": "d2-bron tonen" },
"conversation.label.agentRun": { "translation": "agent-uitvoering" },
"conversation.label.workflow": { "translation": "workflow" },
"conversation.label.error": { "translation": "fout" },
@@ -39,6 +66,12 @@
"conversation.bashTail.label": { "translation": "live tail" },
"conversation.cluster.activity": { "translation": "Activiteit" },
"conversation.cluster.edits": { "translation": "Wijzigingen" },
"conversation.counter.step": { "translation": "1 stap" },
"conversation.counter.steps": { "translation": "{count} stappen" },
"conversation.counter.edit": { "translation": "1 wijziging" },
"conversation.counter.edits": { "translation": "{count} wijzigingen" },
"conversation.counter.starting": { "translation": "starten" },
"conversation.counter.agents": { "translation": "{done}/{total} agents" },
"prompt.permission.allow": { "translation": "1. Toestaan" },
"prompt.permission.allowRemember": { "translation": "2. Toestaan en niet meer vragen" },
"prompt.permission.deny": { "translation": "{n}. Weigeren" },
@@ -62,6 +95,16 @@
"tool.edit.before": { "translation": "— voor" },
"tool.edit.after": { "translation": "+ na" },
"tool.bash.background": { "translation": "achtergrond" },
"tool.name.Read": { "translation": "Lezen" },
"tool.name.Edit": { "translation": "Bewerken" },
"tool.name.MultiEdit": { "translation": "Meervoudig bewerken" },
"tool.name.Write": { "translation": "Schrijven" },
"tool.name.NotebookEdit": { "translation": "Notebook bewerken" },
"tool.name.WebFetch": { "translation": "Web ophalen" },
"tool.name.WebSearch": { "translation": "Web zoeken" },
"tool.name.Task": { "translation": "Taak" },
"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)" },
"permissionBadge.tooltip": { "translation": "Permissiemodus: {mode}. Klik om te wisselen tussen default/acceptEdits/plan; Shift-klik voor bypassPermissions." },
@@ -197,5 +240,10 @@
"settings.claude.effort.label": { "translation": "Effort" },
"settings.claude.effort.help": { "translation": "Reasoning effort voor nieuwe sessies (toegepast via --effort bij het starten)." },
"settings.claude.permissionMode.label": { "translation": "Permissiemodus" },
"settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." }
"settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." },
"settings.claude.account.label": { "translation": "Account" },
"settings.claude.account.registry.label": { "translation": "Accounts" },
"settings.claude.account.registry.help": { "translation": "Geregistreerde Claude-accounts (elk een eigen configmap + login)." },
"settings.claude.account.workspace.label": { "translation": "Account voor deze werkmap" },
"settings.claude.account.workspace.help": { "translation": "Welk Claude-account deze repo gebruikt; Standaard gebruikt de systeemlogin." }
}
+5
View File
@@ -6,6 +6,11 @@
"about.commit": { "translation": "Commit" },
"about.built": { "translation": "Gebouwd" },
"about.repository": { "translation": "Repository" },
"about.checkUpdates": { "translation": "Controleer op updates" },
"about.checking": { "translation": "Bezig met controleren…" },
"about.upToDate": { "translation": "Je hebt de nieuwste versie." },
"about.updateAvailable": { "translation": "clide {version} is beschikbaar — releaseopmerkingen" },
"about.updateFailed": { "translation": "Kon niet op updates controleren" },
"licenses.heading": { "translation": "Meegeleverde afhankelijkheden" },
"licenses.unavailable": { "translation": "Licenties niet beschikbaar." },
"licenses.loading": { "translation": "Laden…" },
@@ -0,0 +1,11 @@
{
"command.detect": { "translation": "Tool-paden opnieuw detecteren" },
"settings.title": { "translation": "Tools" },
"settings.section.binaries": { "translation": "Hulpprogramma's" },
"settings.field.claude.label": { "translation": "Claude-CLI" },
"settings.field.claude.help": { "translation": "Absoluut pad naar het claude-binary; leeg om automatisch te bepalen." },
"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." }
}
+8 -1
View File
@@ -14,6 +14,11 @@
"tips.findInFiles": { "translation": "Zoeken in bestanden" },
"tips.focusMode": { "translation": "Focusmodus" },
"action.openFolder": { "translation": "Map openen…" },
"action.newProject": { "translation": "Nieuw project…" },
"dialog.newProject.title": { "translation": "Nieuw project" },
"dialog.newProject.body": { "translation": "Maakt een git-repo + een CLAUDE.md, en opent het." },
"button.create": { "translation": "Aanmaken" },
"button.creating": { "translation": "Aanmaken…" },
"recent.empty": { "translation": "Geen recente projecten." },
"sticky.label": { "translation": "dit project altijd openen bij opstarten" },
"sticky.tooltip": { "translation": "Dit project altijd openen bij opstarten" },
@@ -30,6 +35,8 @@
"button.opening": { "translation": "Openen…" },
"button.ok": { "translation": "OK" },
"dialog.notRepo.title": { "translation": "Geen git-repo gevonden" },
"dialog.notRepo.body": { "translation": "Een clide-projecthoofdmap vereist een git-repository." },
"dialog.notRepo.body": { "translation": "Een clide-project heeft een git-repository nodig. Deze map als project initialiseren?" },
"dialog.notRepo.initialize": { "translation": "Project initialiseren" },
"button.initializing": { "translation": "Initialiseren…" },
"command.workspace.open-project": { "translation": "Werkruimte: Project openen…" }
}
+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.8.0"
version: "2.9.0"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# T-88 — track dugite-native (bundled git) upstream releases for security
# updates (D-59). Compares the pinned DUGITE_VERSION against the latest
# desktop/dugite-native release and flags CVE / security mentions.
#
# WHY THIS SCRIPT IS THE MAINTENANCE HOME: dugite's binary is FETCHED at build
# time (`make dugite-fetch`), not built, and native/dugite/ is gitignored — so
# there is no `native/dugite/BUILD.md` (D-63) to record it. The pin lives in the
# Makefile (DUGITE_VERSION / DUGITE_COMMIT); this script + its `make dugite-check`
# target are the version-tracking calendar D-59 requires.
#
# CADENCE: run quarterly during normal operation. Run IMMEDIATELY on a git or
# dugite-native security advisory — subscribe to:
# https://github.com/git/git/security/advisories
# https://github.com/desktop/dugite-native/security/advisories
#
# This check is INFORMATIONAL (not a push gate): it reports drift and flags CVE
# mentions. The bump itself is manual per D-63 (reproducibility record) and is
# automated by T-25 (CI). A bump updates: Makefile DUGITE_VERSION/COMMIT, the
# fetched binary in native/dugite/, and assets/licenses.yaml if the bundled git
# or dugite version changed.
set -euo pipefail
cd "$(dirname "$0")/.."
REPO="desktop/dugite-native"
CURRENT="$(grep -E '^DUGITE_VERSION[[:space:]]*:=' Makefile | head -1 | sed -E 's/.*:=[[:space:]]*//')"
# Public read — no auth needed for a quarterly check. gh would raise the rate
# limit but isn't required; curl keeps this dependency-free.
json="$(curl -fsSL -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null || true)"
LATEST="$(printf '%s' "$json" | sed -nE 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/p' | head -1)"
if [[ -z "$LATEST" ]]; then
echo "==> dugite-check: couldn't reach the dugite-native releases API." >&2
echo " Check manually: https://github.com/$REPO/releases" >&2
exit 2
fi
echo "==> dugite-check (T-88): bundled '$CURRENT' vs latest release '$LATEST'"
if [[ "$CURRENT" == "$LATEST" ]]; then
echo " OK — up to date."
else
echo " DRIFT — a newer dugite-native release exists: $CURRENT -> $LATEST"
echo " Bump (D-63 record; machine: T-25): update Makefile DUGITE_VERSION/COMMIT,"
echo " refresh native/dugite/, and assets/licenses.yaml if the git/dugite version changed."
fi
# Loud flag when the latest release notes mention a CVE / security fix — those
# jump the queue regardless of the quarterly cadence.
if printf '%s' "$json" | grep -qiE 'cve-[0-9]{4}|security (fix|advisory|release|update)|vulnerab'; then
echo " !! SECURITY: the latest release notes mention a CVE / security fix — schedule a bump NOW." >&2
fi
+54 -5
View File
@@ -1,9 +1,58 @@
#!/usr/bin/env bash
# CI entry: release pipeline. Stub — wire goreleaser + flutter build
# artifacts later.
# Release finalizer for the single-process Flutter app (T-393).
#
# Run AFTER the `release vX.Y.Z` commit is in place (version bump + changelog
# move — see .claude/skills/git-commit/SKILL.md "Cutting a release"). It:
# 1. reads the version from pubspec.yaml,
# 2. asserts CHANGELOG.md has a dated section for it (not still Unreleased),
# 3. asserts the working tree is clean,
# 4. runs the full gate (make push-check),
# 5. creates the annotated vX.Y.Z tag if missing — closing the loop that
# previously left every release since v2.1.0 untagged.
#
# No goreleaser, no sidecar (both dissolved, D-56). Artifact builds are
# `make build-linux` / `make build-macos`. This never pushes — it prints the
# push command for you to run.
#
# Invoke via `make release`, not directly.
set -euo pipefail
cd "$(dirname "$0")/.."
cd "$(git rev-parse --show-toplevel)"
echo "TODO: goreleaser release (sidecar) + flutter build (app) + publish"
exit 64
version="$(grep -E '^version:' pubspec.yaml | awk '{print $2}')"
if [[ -z "$version" ]]; then
echo "release: could not read 'version:' from pubspec.yaml" >&2
exit 1
fi
tag="v$version"
# 1. The release commit must already have moved Unreleased → [version] — DATE.
if ! grep -qE "^## \[${version//./\\.}\] — [0-9]{4}-[0-9]{2}-[0-9]{2}" CHANGELOG.md; then
echo "release: CHANGELOG.md has no dated section for [$version]." >&2
echo " Cut the release commit first (move Unreleased → '## [$version] — YYYY-MM-DD')." >&2
exit 1
fi
# 2. Clean tree — the release commit is in, nothing dangling.
if [[ -n "$(git status --porcelain)" ]]; then
echo "release: working tree not clean — commit the release first." >&2
exit 1
fi
# 3. Full gate.
echo "release: running the full gate (make push-check)…"
make push-check
# 4. Tag (idempotent), annotated, on the current release commit.
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "release: tag $tag already exists — leaving it."
else
git tag -a "$tag" -m "clide $tag"
echo "release: created tag $tag at $(git rev-parse --short HEAD)."
fi
echo
echo "release: $tag verified and tagged. Next:"
echo " git push origin main --follow-tags # publish the commit + tag"
echo " make build-linux # desktop bundle (Linux)"
echo " make build-macos # desktop bundle (macOS, on a Mac)"
+130
View File
@@ -0,0 +1,130 @@
# Drawing-card JSON schema (T-317 / D-91 / D-103) — draft
Status: **draft for build**, SVG-substrate model (D-103), refined from the
T-317 wireframe set, 2026-06-28.
## Model — what this is, and what it is NOT
- **SVG is the substrate.** The card's primitive / scene-graph layer **is SVG**;
the clide-owned `CustomPaint` **SVG renderer (T-320) is the engine** the rest
builds on (D-103).
- It is **not** the HTML Canvas 2D API and **not** a third-party package. SVG is
a document *format* we render ourselves — "own the rendering stack" holds.
("HTML `<canvas>`" in D-91 was only a mental model, chosen to reject Obsidian's
`.canvas` schema; never an API to port.)
- The card is **two layers**:
1. **SVG content** — painted by the SVG renderer.
2. A thin **Flutter overlay** — the clide chrome that is *not* content
(per-object label/description captions, lightbox affordance), anchored to
SVG elements via `data-*` attributes.
- **Display-only** (D-78); re-rendered from the document. The **graph template
is the live-widget exception** (below).
## Document envelope
```json
{
"card": { "label": "Build pipeline", "description": "…" }, // optional caption (overlay)
"template": "icon", // optional → template sugar
"…template fields…": "…",
"svg": "<svg viewBox='0 0 480 360'>…</svg>" // primitive mode = raw SVG
// or "svgPath": "diagram.svg"
}
```
- **Template mode**`template` names a component; clide lowers it to SVG
(+ overlay anchors).
- **Primitive mode**`svg` (inline) or `svgPath` — arbitrary SVG, the escape
hatch. One less invented format; external SVG / graphviz / mermaid render free.
- Card size comes from the SVG `viewBox` (or `width`/`height`); the painter scales
to the pane width.
## Primitive layer = a bounded SVG subset
Grounded in a real d2 sample + our own templates — **not** a full SVG engine:
- **structure:** `<svg>` (viewBox/width/height, incl. nested `<svg>`), `<g>`
(transform, opacity, class), `<defs>`, `<marker>` (+ marker-start/mid/end,
`orient="auto"`, refX/refY, viewBox) — edge **arrowheads**
- **shapes:** `rect` (rx/ry), `circle`, `ellipse`, `line`, `polyline`,
`polygon`, `path` (full data — `M L H V C S Q T A Z` + relatives)
- **text:** `text` + `tspan` (x/y/dx/dy, font-family incl. **Phosphor**,
font-size/weight, text-anchor, dominant-baseline)
- **raster:** `image` (`href`/`xlink:href`, x/y/w/h, preserveAspectRatio)
- **styling:** presentation attrs (fill, fill-opacity, stroke, stroke-width,
stroke-linecap/linejoin, stroke-dasharray, opacity, color), `transform`
(translate/scale/rotate/matrix); `class=` resolved by the normalizer below
- **deferred v1:** `<mask>` (d2 masks connections for clean edge/node joins) —
ignore and lean on node-over-edge paint order; add only if output looks wrong
- **out:** `foreignObject`, filters, `<animate>`/SMIL, scripting,
`<use>`/`<symbol>`, gradients, patterns, `clipPath` → **mermaid is not a
launch target** (it leans on `foreignObject`)
`color` everywhere is an **arbitrary value** (hex / named) — content, not a clide
`SurfaceTokens` token (D-7 governs clide chrome, not rendered content).
### Class styling → inline-normalize (not a render-time CSS engine)
d2 / graphviz emit a `<style>` block of flat single-class selectors
(`.fill-B1`, `.shape`, `.connection`, `.text-bold` → presentation props), not
inline attributes. A **preprocessing normalizer** parses `<style>` into
class→props and merges each element's class props into inline presentation
attributes (inline wins), then drops `<style>`. The painter therefore only ever
sees inline attrs — a pure, testable presentation-attribute renderer. The
normalizer is a bounded, fixture-testable transform (real d2 + graphviz output).
## Overlay (clide chrome, layered over the SVG)
Flutter widgets anchored to SVG elements that carry:
- `data-label` → themed caption beneath the element's bounding box
- `data-description` → secondary caption line
- `data-lightbox` (on `<image>`) → click-to-zoom affordance
Templates emit these attributes; raw-SVG authors may add them. The icon
template's `data-label` is also the bridge to the interaction-zone choice list.
## Templates (lower to SVG + overlay)
| template | lowers to | ticket |
|----------|-----------|--------|
| `image` | `<image href>` + `data-lightbox` + caption attrs | T-316 |
| `icon` | `<text font=Phosphor>` glyphs at 10,11,12,13,14,15,18,20,24,32,48 + hero 52; per-item `data-label`/`data-description`/color | T-313 |
| `compare` | two+ `<image>` side by side in a `<g>`, per-image `data-lightbox` + captions | T-319 |
| `svg` | identity — the source *is* the SVG | T-320 |
| `d2` | compile d2 → SVG → render | T-494 |
| `graph` | **exception** — hosts the live native graph subsystem widget (D-46 / T-323), not static SVG | T-321 |
## CLI / transport (D-6 parity)
`clide draw --file doc.json` (or inline JSON). **Flutter-free** handler validates,
publishes on a `draw` MessageBus channel; the Claude extension injects the card —
mirroring `image.show`. The shipped `image show` (T-249/T-252) stays as
convenience and migrates onto this card later (D-91). `--stdin` deferred (T-315);
`--file` is the path.
## Error contract
Unknown `template`, unparseable / unsupported SVG, bad `href` / glyph / `color`
→ honest `IpcError` (`userError` / `notFound`), surfaced like `image.show`
**never a blank card**.
## Build sequence (D-103)
1. **T-320 — the SVG renderer (engine):** parse + paint the bounded SVG subset.
2. **T-318 — envelope + template dispatch + the Flutter overlay** (`data-*`
captions / lightbox) on top of the renderer.
3. **Templates:** image / icon (T-316 / T-313) → compare (T-319) → d2 (T-494) →
graph (T-321, after the graph subsystem T-323).
## Decisions
- **SVG subset boundary (T-320): RESOLVED** — see the subset above, grounded in
a real d2 sample; expand deliberately.
- **Class styling (T-320): RESOLVED** — inline-normalize, not a render-time CSS
engine (above).
- **Tool-PATH resolution: RESOLVED** — explicit user-scope override + first-run
auto-detect (D-104 / T-495); the d2 binary resolves through it.
- **D2 compiler delivery (T-494): open** — shell out to a `d2` binary as a
pql-style supporter tool (resolution now handled by D-104), vs. vendor.
- **Graph (T-321): gated** on the native graph subsystem (D-46 / T-323).
@@ -0,0 +1,64 @@
{
"name": "Drawing Card Core (T-318)",
"shapes": {
"title": { "type": "Text", "left": 60, "top": 38, "text": "clide drawing card — core: canvas engine + JSON schema + template dispatch (T-318)", "fontColor": "#c8d0e0", "fontSize": 14 },
"subtitle": { "type": "Text", "left": 60, "top": 60, "text": "the dispatch shell under every card type — a JSON document renders as raw primitives OR via a named template that lowers onto the same primitive scene (hybrid)", "fontColor": "#6a7280", "fontSize": 11 },
"cliLine": { "type": "Text", "left": 60, "top": 84, "text": "clide draw --file doc.json — Flutter-free handler → MessageBus → Claude-extension injection (mirrors image.show)", "fontColor": "#6a7280", "fontSize": 11 },
"inputBox": { "type": "Rectangle", "left": 400, "top": 110, "width": 300, "height": 58, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
"inT": { "type": "Text", "parent": "inputBox", "left": 418, "top": 122, "text": "Drawing-card JSON document", "fontColor": "#e2e8f5", "fontSize": 13 },
"inT2": { "type": "Text", "parent": "inputBox", "left": 418, "top": 144, "text": "{ \"template\"?: \"…\", \"objects\": [ … ] }", "fontColor": "#9aa3b5", "fontSize": 11 },
"coreBox": { "type": "Rectangle", "left": 360, "top": 200, "width": 380, "height": 60, "fillColor": "#1a1e24", "strokeColor": "#4a5570", "corners": [8, 8, 8, 8] },
"coreT": { "type": "Text", "parent": "coreBox", "left": 384, "top": 212, "text": "Drawing-card core (T-318)", "fontColor": "#e2e8f5", "fontSize": 14 },
"coreT2": { "type": "Text", "parent": "coreBox", "left": 384, "top": 234, "text": "JSON schema · primitive renderer · template dispatch", "fontColor": "#8a93a6", "fontSize": 11 },
"primBox": { "type": "Rectangle", "left": 110, "top": 312, "width": 360, "height": 150, "fillColor": "#161a20", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"primH": { "type": "Text", "parent": "primBox", "left": 130, "top": 326, "text": "PRIMITIVE scene-graph", "fontColor": "#e2e8f5", "fontSize": 13 },
"primS": { "type": "Text", "parent": "primBox", "left": 130, "top": 348, "text": "objects drawn at coordinates", "fontColor": "#8a93a6", "fontSize": 11 },
"pc1": { "type": "Rectangle", "parent": "primBox", "left": 130, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"pc1t": { "type": "Text", "parent": "pc1", "left": 144, "top": 382, "text": "rect", "fontColor": "#c8d0e0", "fontSize": 10 },
"pc2": { "type": "Rectangle", "parent": "primBox", "left": 194, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"pc2t": { "type": "Text", "parent": "pc2", "left": 208, "top": 382, "text": "line", "fontColor": "#c8d0e0", "fontSize": 10 },
"pc3": { "type": "Rectangle", "parent": "primBox", "left": 258, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"pc3t": { "type": "Text", "parent": "pc3", "left": 272, "top": 382, "text": "text", "fontColor": "#c8d0e0", "fontSize": 10 },
"pc4": { "type": "Rectangle", "parent": "primBox", "left": 322, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"pc4t": { "type": "Text", "parent": "pc4", "left": 334, "top": 382, "text": "glyph", "fontColor": "#c8d0e0", "fontSize": 10 },
"pc5": { "type": "Rectangle", "parent": "primBox", "left": 386, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"pc5t": { "type": "Text", "parent": "pc5", "left": 398, "top": 382, "text": "image", "fontColor": "#c8d0e0", "fontSize": 10 },
"primNote": { "type": "Text", "parent": "primBox", "left": 130, "top": 420, "text": "{ \"type\": \"rect\", \"x\":.., \"y\":.., \"w\":.., \"h\":.. }", "fontColor": "#6a7280", "fontSize": 10 },
"tmplBox": { "type": "Rectangle", "left": 640, "top": 312, "width": 360, "height": 212, "fillColor": "#161a20", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"tmplH": { "type": "Text", "parent": "tmplBox", "left": 660, "top": 326, "text": "TEMPLATE dispatch", "fontColor": "#e2e8f5", "fontSize": 13 },
"tmplS": { "type": "Text", "parent": "tmplBox", "left": 660, "top": 348, "text": "named component → predefined renderer (children)", "fontColor": "#8a93a6", "fontSize": 11 },
"tc1": { "type": "Rectangle", "parent": "tmplBox", "left": 660, "top": 374, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"tc1t": { "type": "Text", "parent": "tc1", "left": 674, "top": 381, "text": "image · T-316", "fontColor": "#c8d0e0", "fontSize": 10 },
"tc2": { "type": "Rectangle", "parent": "tmplBox", "left": 822, "top": 374, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"tc2t": { "type": "Text", "parent": "tc2", "left": 836, "top": 381, "text": "icon · T-313", "fontColor": "#c8d0e0", "fontSize": 10 },
"tc3": { "type": "Rectangle", "parent": "tmplBox", "left": 660, "top": 408, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"tc3t": { "type": "Text", "parent": "tc3", "left": 674, "top": 415, "text": "svg · T-320", "fontColor": "#c8d0e0", "fontSize": 10 },
"tc4": { "type": "Rectangle", "parent": "tmplBox", "left": 822, "top": 408, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"tc4t": { "type": "Text", "parent": "tc4", "left": 836, "top": 415, "text": "d2 · T-494", "fontColor": "#c8d0e0", "fontSize": 10 },
"tc5": { "type": "Rectangle", "parent": "tmplBox", "left": 660, "top": 442, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"tc5t": { "type": "Text", "parent": "tc5", "left": 674, "top": 449, "text": "compare · T-319", "fontColor": "#c8d0e0", "fontSize": 10 },
"tc6": { "type": "Rectangle", "parent": "tmplBox", "left": 822, "top": 442, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
"tc6t": { "type": "Text", "parent": "tc6", "left": 836, "top": 449, "text": "graph · T-321", "fontColor": "#c8d0e0", "fontSize": 10 },
"tmplNote": { "type": "Text", "parent": "tmplBox", "left": 660, "top": 486, "text": "{ \"template\": \"svg\", \"source\": … }", "fontColor": "#6a7280", "fontSize": 10 },
"lowerLab": { "type": "Text", "left": 486, "top": 380, "text": "lower onto the", "fontColor": "#c8d8f0", "fontSize": 10 },
"lowerLab2": { "type": "Text", "left": 486, "top": 396, "text": "primitive scene (hybrid)", "fontColor": "#c8d8f0", "fontSize": 10 },
"canvasBox": { "type": "Rectangle", "left": 320, "top": 556, "width": 400, "height": 64, "fillColor": "#1a1e24", "strokeColor": "#4a5570", "corners": [8, 8, 8, 8] },
"cvT": { "type": "Text", "parent": "canvasBox", "left": 344, "top": 568, "text": "CustomPaint canvas — in the conversation pane", "fontColor": "#e2e8f5", "fontSize": 12 },
"cvT2": { "type": "Text", "parent": "canvasBox", "left": 344, "top": 590, "text": "+ shared per-object label / description widget (only when present)", "fontColor": "#8a93a6", "fontSize": 10 },
"foot": { "type": "Text", "left": 60, "top": 638, "text": "Display-only (D-78). Unknown template / primitive → clear userError. Templates (image · icon · svg · d2 · compare · graph) are separate children of this engine.", "fontColor": "#6a7280", "fontSize": 11 }
},
"connectors": {
"c1": { "tailId": "inputBox", "headId": "coreBox", "strokeColor": "#c8d8f0" },
"c2": { "tailId": "coreBox", "headId": "primBox", "strokeColor": "#6a7a98" },
"c3": { "tailId": "coreBox", "headId": "tmplBox", "strokeColor": "#6a7a98" },
"c4": { "tailId": "tmplBox", "headId": "primBox", "strokeColor": "#c8d8f0" },
"c5": { "tailId": "primBox", "headId": "canvasBox", "strokeColor": "#c8d8f0" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

@@ -0,0 +1,40 @@
{
"name": "Compare / Before-After Card (T-319)",
"shapes": {
"title": { "type": "Text", "left": 60, "top": 40, "text": "clide drawing card — compare / before-after (T-319)", "fontColor": "#c8d0e0", "fontSize": 14 },
"subtitle": { "type": "Text", "left": 60, "top": 62, "text": "two (or more) images side by side; each with an optional label + description and lightbox-on-click — shares the image template", "fontColor": "#6a7280", "fontSize": 11 },
"jsonBox": { "type": "Rectangle", "left": 60, "top": 92, "width": 600, "height": 116, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
"j1": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 104, "text": "{ \"template\": \"compare\", \"items\": [", "fontColor": "#9aa3b5", "fontSize": 11 },
"j2": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 126, "text": " { \"path\": \"before.png\", \"label\": \"Before\", \"description\": \"…\" },", "fontColor": "#9aa3b5", "fontSize": 11 },
"j3": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 148, "text": " { \"path\": \"after.png\", \"label\": \"After\", \"description\": \"…\" }", "fontColor": "#9aa3b5", "fontSize": 11 },
"j4": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 170, "text": "] }", "fontColor": "#9aa3b5", "fontSize": 11 },
"cli": { "type": "Text", "left": 60, "top": 226, "text": "clide draw --file compare.json", "fontColor": "#c8d8f0", "fontSize": 12 },
"card": { "type": "Rectangle", "left": 60, "top": 262, "width": 960, "height": 278, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"cardLabel": { "type": "Text", "parent": "card", "left": 84, "top": 282, "text": "HUD — before / after", "fontColor": "#e2e8f5", "fontSize": 16 },
"vdiv": { "type": "Rectangle", "parent": "card", "left": 540, "top": 300, "width": 1, "height": 216, "fillColor": "#333340", "strokeColor": "#333340" },
"labelL": { "type": "Text", "parent": "card", "left": 84, "top": 312, "text": "Before", "fontColor": "#e2e8f5", "fontSize": 14 },
"descL": { "type": "Text", "parent": "card", "left": 84, "top": 334, "text": "cramped status row", "fontColor": "#8a93a6", "fontSize": 11 },
"imgL": { "type": "Rectangle", "parent": "card", "left": 84, "top": 356, "width": 430, "height": 150, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4] },
"imgLname": { "type": "Text", "parent": "imgL", "left": 256, "top": 424, "text": "before.png", "fontColor": "#6a7280", "fontSize": 12 },
"zoomL": { "type": "Rectangle", "parent": "imgL", "left": 470, "top": 364, "width": 36, "height": 18, "fillColor": "#1a1e24", "strokeColor": "#3a4250", "corners": [3, 3, 3, 3] },
"zoomLt": { "type": "Text", "parent": "zoomL", "left": 476, "top": 367, "text": "zoom", "fontColor": "#8a93a6", "fontSize": 9 },
"labelR": { "type": "Text", "parent": "card", "left": 566, "top": 312, "text": "After", "fontColor": "#e2e8f5", "fontSize": 14 },
"descR": { "type": "Text", "parent": "card", "left": 566, "top": 334, "text": "roomy, aligned status row", "fontColor": "#8a93a6", "fontSize": 11 },
"imgR": { "type": "Rectangle", "parent": "card", "left": 566, "top": 356, "width": 430, "height": 150, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4] },
"imgRname": { "type": "Text", "parent": "imgR", "left": 740, "top": 424, "text": "after.png", "fontColor": "#6a7280", "fontSize": 12 },
"zoomR": { "type": "Rectangle", "parent": "imgR", "left": 952, "top": 364, "width": 36, "height": 18, "fillColor": "#1a1e24", "strokeColor": "#3a4250", "corners": [3, 3, 3, 3] },
"zoomRt": { "type": "Text", "parent": "zoomR", "left": 958, "top": 367, "text": "zoom", "fontColor": "#8a93a6", "fontSize": 9 },
"lightboxCap": { "type": "Text", "parent": "card", "left": 84, "top": 516, "text": "click either image → lightbox (shared with the image card)", "fontColor": "#6a7280", "fontSize": 10 },
"foot": { "type": "Text", "left": 60, "top": 562, "text": "Two or more items, side by side. Per-object label + description via the shared widget (T-318). Display-only (D-78).", "fontColor": "#6a7280", "fontSize": 11 }
},
"connectors": {
"flow": { "tailId": "jsonBox", "headId": "card", "strokeColor": "#3a4250" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,58 @@
{
"name": "D2 Diagram Card",
"shapes": {
"title": { "type": "Text", "left": 60, "top": 40, "text": "clide drawing card — D2 diagram (T-494 · separate type, reuses the SVG widget T-320)", "fontColor": "#c8d0e0", "fontSize": 14 },
"subtitle": { "type": "Text", "left": 60, "top": 62, "text": "the rendered diagram leads; the d2 source folds into a collapsed “view d2 source” disclosure (collapser pattern, T-305)", "fontColor": "#6a7280", "fontSize": 11 },
"card1": { "type": "Rectangle", "left": 60, "top": 100, "width": 460, "height": 302, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"label1": { "type": "Text", "parent": "card1", "left": 84, "top": 122, "text": "Build pipeline", "fontColor": "#e2e8f5", "fontSize": 16 },
"region1": { "type": "Rectangle", "parent": "card1", "left": 84, "top": 154, "width": 412, "height": 180, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"wm1": { "type": "Text", "parent": "region1", "left": 462, "top": 314, "text": "SVG", "fontColor": "#39424f", "fontSize": 11 },
"n1": { "type": "Rectangle", "parent": "region1", "left": 108, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n1t": { "type": "Text", "parent": "n1", "left": 124, "top": 241, "text": "fetch", "fontColor": "#c8d0e0", "fontSize": 11 },
"n2": { "type": "Rectangle", "parent": "region1", "left": 212, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n2t": { "type": "Text", "parent": "n2", "left": 228, "top": 241, "text": "build", "fontColor": "#c8d0e0", "fontSize": 11 },
"n3": { "type": "Rectangle", "parent": "region1", "left": 340, "top": 196, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n3t": { "type": "Text", "parent": "n3", "left": 360, "top": 205, "text": "test", "fontColor": "#c8d0e0", "fontSize": 11 },
"n4": { "type": "Rectangle", "parent": "region1", "left": 340, "top": 268, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n4t": { "type": "Text", "parent": "n4", "left": 352, "top": 277, "text": "deploy", "fontColor": "#c8d0e0", "fontSize": 11 },
"disc1": { "type": "Rectangle", "parent": "card1", "left": 84, "top": 348, "width": 412, "height": 34, "fillColor": "#181c22", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"chev1": { "type": "Text", "parent": "disc1", "left": 98, "top": 356, "text": "▸", "fontColor": "#8a93a6", "fontSize": 13 },
"disc1t": { "type": "Text", "parent": "disc1", "left": 118, "top": 358, "text": "view d2 source", "fontColor": "#8a93a6", "fontSize": 12 },
"stateCap1": { "type": "Text", "left": 84, "top": 412, "text": "collapsed — diagram only", "fontColor": "#6a7280", "fontSize": 11 },
"card2": { "type": "Rectangle", "left": 560, "top": 100, "width": 460, "height": 384, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"label2": { "type": "Text", "parent": "card2", "left": 584, "top": 122, "text": "Build pipeline", "fontColor": "#e2e8f5", "fontSize": 16 },
"region2": { "type": "Rectangle", "parent": "card2", "left": 584, "top": 154, "width": 412, "height": 180, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"wm2": { "type": "Text", "parent": "region2", "left": 962, "top": 314, "text": "SVG", "fontColor": "#39424f", "fontSize": 11 },
"m1": { "type": "Rectangle", "parent": "region2", "left": 608, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"m1t": { "type": "Text", "parent": "m1", "left": 624, "top": 241, "text": "fetch", "fontColor": "#c8d0e0", "fontSize": 11 },
"m2": { "type": "Rectangle", "parent": "region2", "left": 712, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"m2t": { "type": "Text", "parent": "m2", "left": 728, "top": 241, "text": "build", "fontColor": "#c8d0e0", "fontSize": 11 },
"m3": { "type": "Rectangle", "parent": "region2", "left": 840, "top": 196, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"m3t": { "type": "Text", "parent": "m3", "left": 860, "top": 205, "text": "test", "fontColor": "#c8d0e0", "fontSize": 11 },
"m4": { "type": "Rectangle", "parent": "region2", "left": 840, "top": 268, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"m4t": { "type": "Text", "parent": "m4", "left": 852, "top": 277, "text": "deploy", "fontColor": "#c8d0e0", "fontSize": 11 },
"disc2": { "type": "Rectangle", "parent": "card2", "left": 584, "top": 348, "width": 412, "height": 34, "fillColor": "#181c22", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"chev2": { "type": "Text", "parent": "disc2", "left": 598, "top": 356, "text": "▾", "fontColor": "#c8d0e0", "fontSize": 13 },
"disc2t": { "type": "Text", "parent": "disc2", "left": 618, "top": 358, "text": "view d2 source", "fontColor": "#c8d0e0", "fontSize": 12 },
"srcBox": { "type": "Rectangle", "parent": "card2", "left": 584, "top": 388, "width": 412, "height": 82, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"sd1": { "type": "Text", "parent": "srcBox", "left": 600, "top": 398, "text": "direction: right", "fontColor": "#9aa3b5", "fontSize": 11 },
"sd2": { "type": "Text", "parent": "srcBox", "left": 600, "top": 420, "text": "fetch -> build -> test", "fontColor": "#9aa3b5", "fontSize": 11 },
"sd3": { "type": "Text", "parent": "srcBox", "left": 600, "top": 442, "text": "build -> deploy", "fontColor": "#9aa3b5", "fontSize": 11 },
"stateCap2": { "type": "Text", "left": 584, "top": 494, "text": "expanded — d2 source revealed", "fontColor": "#6a7280", "fontSize": 11 },
"expandLab": { "type": "Text", "left": 528, "top": 330, "text": "expand", "fontColor": "#c8d8f0", "fontSize": 10 },
"foot": { "type": "Text", "left": 60, "top": 520, "text": "D2 → SVG compile in front; renders via the T-320 SVG widget. clide draw --file pipeline.d2 (type inferred from .d2)", "fontColor": "#6a7280", "fontSize": 11 }
},
"connectors": {
"e1": { "tailId": "n1", "headId": "n2", "strokeColor": "#6a7a98" },
"e2": { "tailId": "n2", "headId": "n3", "strokeColor": "#6a7a98" },
"e3": { "tailId": "n2", "headId": "n4", "strokeColor": "#6a7a98" },
"f1": { "tailId": "m1", "headId": "m2", "strokeColor": "#6a7a98" },
"f2": { "tailId": "m2", "headId": "m3", "strokeColor": "#6a7a98" },
"f3": { "tailId": "m2", "headId": "m4", "strokeColor": "#6a7a98" },
"expand": { "tailId": "disc1", "headId": "disc2", "strokeColor": "#3a4250" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,45 @@
{
"name": "Graph Render Card (T-321)",
"shapes": {
"title": { "type": "Text", "left": 60, "top": 40, "text": "clide drawing card — graph render (T-321)", "fontColor": "#c8d0e0", "fontSize": 14 },
"subtitle": { "type": "Text", "left": 60, "top": 62, "text": "renders a node/edge graph from JSON — embeds clide's native graph subsystem (CustomPaint, D-46 / T-323), not a new renderer", "fontColor": "#6a7280", "fontSize": 11 },
"jsonBox": { "type": "Rectangle", "left": 60, "top": 110, "width": 400, "height": 196, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
"j1": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 124, "text": "{ \"template\": \"graph\",", "fontColor": "#9aa3b5", "fontSize": 11 },
"j2": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 146, "text": " \"nodes\": [\"app\",\"ipc\",\"pty\",\"git\",\"pql\"],", "fontColor": "#9aa3b5", "fontSize": 11 },
"j3": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 168, "text": " \"edges\": [[\"app\",\"ipc\"],[\"ipc\",\"pty\"],", "fontColor": "#9aa3b5", "fontSize": 11 },
"j4": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 190, "text": " [\"ipc\",\"git\"],[\"git\",\"pql\"],[\"app\",\"git\"]],", "fontColor": "#9aa3b5", "fontSize": 11 },
"j5": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 212, "text": " \"label\": \"subsystem links\" }", "fontColor": "#9aa3b5", "fontSize": 11 },
"cli": { "type": "Text", "left": 60, "top": 328, "text": "clide draw --file graph.json", "fontColor": "#c8d8f0", "fontSize": 12 },
"cliNote": { "type": "Text", "left": 60, "top": 350, "text": "(nodes + edges inline, or a path to a graph file)", "fontColor": "#6a7280", "fontSize": 11 },
"card": { "type": "Rectangle", "left": 560, "top": 110, "width": 480, "height": 360, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"region": { "type": "Rectangle", "parent": "card", "left": 584, "top": 134, "width": 432, "height": 256, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"wm": { "type": "Text", "parent": "region", "left": 980, "top": 372, "text": "graph", "fontColor": "#39424f", "fontSize": 11 },
"na": { "type": "Ellipse", "parent": "region", "left": 644, "top": 168, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
"nat": { "type": "Text", "parent": "na", "left": 657, "top": 184, "text": "app", "fontColor": "#c8d0e0", "fontSize": 11 },
"ni": { "type": "Ellipse", "parent": "region", "left": 766, "top": 206, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
"nit": { "type": "Text", "parent": "ni", "left": 781, "top": 222, "text": "ipc", "fontColor": "#c8d0e0", "fontSize": 11 },
"np": { "type": "Ellipse", "parent": "region", "left": 898, "top": 166, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
"npt": { "type": "Text", "parent": "np", "left": 911, "top": 182, "text": "pty", "fontColor": "#c8d0e0", "fontSize": 11 },
"ng": { "type": "Ellipse", "parent": "region", "left": 760, "top": 306, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
"ngt": { "type": "Text", "parent": "ng", "left": 773, "top": 322, "text": "git", "fontColor": "#c8d0e0", "fontSize": 11 },
"nq": { "type": "Ellipse", "parent": "region", "left": 904, "top": 298, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
"nqt": { "type": "Text", "parent": "nq", "left": 917, "top": 314, "text": "pql", "fontColor": "#c8d0e0", "fontSize": 11 },
"label": { "type": "Text", "parent": "card", "left": 584, "top": 402, "text": "subsystem links", "fontColor": "#e2e8f5", "fontSize": 14 },
"desc": { "type": "Text", "parent": "card", "left": 584, "top": 424, "text": "how the core pieces connect", "fontColor": "#8a93a6", "fontSize": 11 },
"foot": { "type": "Text", "left": 60, "top": 500, "text": "Embeds clide's native graph subsystem (CustomPaint, D-46 / T-323) — the card hosts it, doesn't fork it. Label + description beneath via T-318. Display-only (D-78).", "fontColor": "#6a7280", "fontSize": 11 }
},
"connectors": {
"flow": { "tailId": "jsonBox", "headId": "card", "strokeColor": "#3a4250" },
"ea": { "tailId": "na", "headId": "ni", "strokeColor": "#6a7a98" },
"eb": { "tailId": "ni", "headId": "np", "strokeColor": "#6a7a98" },
"ec": { "tailId": "ni", "headId": "ng", "strokeColor": "#6a7a98" },
"ed": { "tailId": "ng", "headId": "nq", "strokeColor": "#6a7a98" },
"ee": { "tailId": "na", "headId": "ng", "strokeColor": "#6a7a98" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,78 @@
{
"name": "Icon Glyph Card (T-313)",
"shapes": {
"title": { "type": "Text", "left": 60, "top": 44, "text": "clide icon show — Phosphor glyph card (T-313)", "fontColor": "#c8d0e0", "fontSize": 14 },
"subtitle": { "type": "Text", "left": 60, "top": 66, "text": "per-entry label + description · hero (52) + a strip of size samples from 10 up to 48 px", "fontColor": "#6a7280", "fontSize": 11 },
"cli1": { "type": "Text", "left": 60, "top": 98, "text": "clide icon show gear folder gauge", "fontColor": "#c8d8f0", "fontSize": 12 },
"cli1n": { "type": "Text", "left": 372, "top": 98, "text": "— bare variadic preview", "fontColor": "#6a7280", "fontSize": 11 },
"cli2": { "type": "Text", "left": 60, "top": 118, "text": "clide icon show --file icons.json", "fontColor": "#c8d8f0", "fontSize": 12 },
"cli2n": { "type": "Text", "left": 372, "top": 118, "text": "— labelled entries: [{icon, label, description}, …]", "fontColor": "#6a7280", "fontSize": 11 },
"glyphNote": { "type": "Text", "left": 60, "top": 140, "text": "(wireframe: neutral marks stand in for glyphs — Frame0 has no Phosphor font; real card paints via PhosphorIconPainter)", "fontColor": "#566070", "fontSize": 10 },
"card": { "type": "Rectangle", "left": 60, "top": 168, "width": 960, "height": 350, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"hero1": { "type": "Rectangle", "left": 84, "top": 192, "width": 92, "height": 92, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"hero1g": { "type": "Ellipse", "left": 106, "top": 214, "width": 48, "height": 48, "fillColor": "#c8d8f0", "strokeColor": "#c8d8f0" },
"hero1cap": { "type": "Text", "left": 90, "top": 290, "text": "hero · 52", "fontColor": "#6a7280", "fontSize": 10 },
"label1": { "type": "Text", "left": 200, "top": 192, "text": "Settings", "fontColor": "#e2e8f5", "fontSize": 16 },
"desc1": { "type": "Text", "left": 200, "top": 216, "text": "global scope", "fontColor": "#8a93a6", "fontSize": 12 },
"sizecap1": { "type": "Text", "left": 200, "top": 240, "text": "samples (px)", "fontColor": "#6a7280", "fontSize": 10 },
"a1": { "type": "Rectangle", "left": 200, "top": 302, "width": 10, "height": 10, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a1l": { "type": "Text", "left": 200, "top": 318, "text": "10", "fontColor": "#6a7280", "fontSize": 9 },
"a2": { "type": "Rectangle", "left": 218, "top": 301, "width": 11, "height": 11, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a2l": { "type": "Text", "left": 218, "top": 318, "text": "11", "fontColor": "#6a7280", "fontSize": 9 },
"a3": { "type": "Rectangle", "left": 237, "top": 300, "width": 12, "height": 12, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a3l": { "type": "Text", "left": 238, "top": 318, "text": "12", "fontColor": "#6a7280", "fontSize": 9 },
"a4": { "type": "Rectangle", "left": 257, "top": 299, "width": 13, "height": 13, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a4l": { "type": "Text", "left": 258, "top": 318, "text": "13", "fontColor": "#6a7280", "fontSize": 9 },
"a5": { "type": "Rectangle", "left": 278, "top": 298, "width": 14, "height": 14, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a5l": { "type": "Text", "left": 280, "top": 318, "text": "14", "fontColor": "#6a7280", "fontSize": 9 },
"a6": { "type": "Rectangle", "left": 300, "top": 297, "width": 15, "height": 15, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a6l": { "type": "Text", "left": 302, "top": 318, "text": "15", "fontColor": "#6a7280", "fontSize": 9 },
"a7": { "type": "Rectangle", "left": 323, "top": 294, "width": 18, "height": 18, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a7l": { "type": "Text", "left": 327, "top": 318, "text": "18", "fontColor": "#6a7280", "fontSize": 9 },
"a8": { "type": "Rectangle", "left": 349, "top": 292, "width": 20, "height": 20, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a8l": { "type": "Text", "left": 354, "top": 318, "text": "20", "fontColor": "#6a7280", "fontSize": 9 },
"a9": { "type": "Rectangle", "left": 377, "top": 288, "width": 24, "height": 24, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a9l": { "type": "Text", "left": 384, "top": 318, "text": "24", "fontColor": "#6a7280", "fontSize": 9 },
"a10": { "type": "Rectangle", "left": 409, "top": 280, "width": 32, "height": 32, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a10l": { "type": "Text", "left": 420, "top": 318, "text": "32", "fontColor": "#6a7280", "fontSize": 9 },
"a11": { "type": "Rectangle", "left": 449, "top": 264, "width": 48, "height": 48, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"a11l": { "type": "Text", "left": 468, "top": 318, "text": "48", "fontColor": "#6a7280", "fontSize": 9 },
"divider": { "type": "Rectangle", "left": 84, "top": 336, "width": 912, "height": 1, "fillColor": "#333340", "strokeColor": "#333340" },
"hero2": { "type": "Rectangle", "left": 84, "top": 360, "width": 92, "height": 92, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"hero2g": { "type": "Ellipse", "left": 106, "top": 382, "width": 48, "height": 48, "fillColor": "#9fb0c8", "strokeColor": "#9fb0c8" },
"hero2cap": { "type": "Text", "left": 90, "top": 458, "text": "hero · 52", "fontColor": "#6a7280", "fontSize": 10 },
"label2": { "type": "Text", "left": 200, "top": 360, "text": "Folder", "fontColor": "#e2e8f5", "fontSize": 16 },
"desc2": { "type": "Text", "left": 200, "top": 384, "text": "workspace tree", "fontColor": "#8a93a6", "fontSize": 12 },
"sizecap2": { "type": "Text", "left": 200, "top": 408, "text": "same scale, per entry", "fontColor": "#6a7280", "fontSize": 10 },
"b1": { "type": "Rectangle", "left": 200, "top": 470, "width": 10, "height": 10, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b2": { "type": "Rectangle", "left": 218, "top": 469, "width": 11, "height": 11, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b3": { "type": "Rectangle", "left": 237, "top": 468, "width": 12, "height": 12, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b4": { "type": "Rectangle", "left": 257, "top": 467, "width": 13, "height": 13, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b5": { "type": "Rectangle", "left": 278, "top": 466, "width": 14, "height": 14, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b6": { "type": "Rectangle", "left": 300, "top": 465, "width": 15, "height": 15, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b7": { "type": "Rectangle", "left": 323, "top": 462, "width": 18, "height": 18, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b8": { "type": "Rectangle", "left": 349, "top": 460, "width": 20, "height": 20, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b9": { "type": "Rectangle", "left": 377, "top": 456, "width": 24, "height": 24, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b10": { "type": "Rectangle", "left": 409, "top": 448, "width": 32, "height": 32, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"b11": { "type": "Rectangle", "left": 449, "top": 432, "width": 48, "height": 48, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
"tagMeta": { "type": "Text", "left": 880, "top": 196, "text": "label + description", "fontColor": "#c8d8f0", "fontSize": 11 },
"tagHero": { "type": "Text", "left": 880, "top": 226, "text": "hero · 52", "fontColor": "#c8d8f0", "fontSize": 11 },
"tagSizes": { "type": "Text", "left": 880, "top": 286, "text": "size samples 10→48", "fontColor": "#c8d8f0", "fontSize": 11 },
"foot1": { "type": "Text", "left": 60, "top": 536, "text": "Display-only (D-78): the card shows options; selection happens in the convo box. Per-entry labels bridge card → interaction-zone choice list.", "fontColor": "#6a7280", "fontSize": 11 },
"foot2": { "type": "Text", "left": 60, "top": 556, "text": "Without label/description, entries collapse to a bare glyph grid.", "fontColor": "#566070", "fontSize": 11 }
},
"connectors": {
"cMeta": { "tailId": "tagMeta", "headId": "label1", "strokeColor": "#3a4250" },
"cHero": { "tailId": "tagHero", "headId": "hero1", "strokeColor": "#3a4250" },
"cSizes": { "tailId": "tagSizes", "headId": "a11", "strokeColor": "#3a4250" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,71 @@
{
"name": "Image Annotation Flow (T-316)",
"shapes": {
"title": {
"type": "Text",
"left": 60, "top": 56,
"text": "clide image show — annotated image card (T-316, variant a: text metadata)",
"fontColor": "#c8d0e0", "fontSize": 14
},
"jsonBox": {
"type": "Rectangle",
"left": 60, "top": 110, "width": 380, "height": 200,
"fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6]
},
"j1": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 124, "text": "{", "fontColor": "#9aa3b5", "fontSize": 11 },
"j2": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 146, "text": " \"path\": \"docs/shot.png\",", "fontColor": "#9aa3b5", "fontSize": 11 },
"j3": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 168, "text": " \"label\": \"HUD v3\",", "fontColor": "#9aa3b5", "fontSize": 11 },
"j4": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 190, "text": " \"description\": \"status row cramped\",", "fontColor": "#9aa3b5", "fontSize": 11 },
"j5": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 212, "text": " \"caption\": \"before the fix\"", "fontColor": "#9aa3b5", "fontSize": 11 },
"j6": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 234, "text": "}", "fontColor": "#9aa3b5", "fontSize": 11 },
"cli": {
"type": "Text",
"left": 60, "top": 330,
"text": "clide image show docs/shot.png --file meta.json",
"fontColor": "#c8d8f0", "fontSize": 12
},
"cliNote": {
"type": "Text",
"left": 60, "top": 354,
"text": "(same payload via --stdin once T-315 lands; --caption form still works)",
"fontColor": "#6a7280", "fontSize": 11
},
"card": {
"type": "Rectangle",
"left": 560, "top": 110, "width": 480, "height": 430,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
},
"cardLabel": { "type": "Text", "parent": "card", "left": 584, "top": 134, "text": "HUD v3", "fontColor": "#e2e8f5", "fontSize": 16 },
"cardDesc": { "type": "Text", "parent": "card", "left": 584, "top": 164, "text": "Status row cramped — the clock overlaps the battery at narrow widths.", "fontColor": "#8a93a6", "fontSize": 12 },
"cardImage": {
"type": "Rectangle",
"parent": "card",
"left": 584, "top": 200, "width": 432, "height": 250,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cardImageName": { "type": "Text", "parent": "cardImage", "left": 730, "top": 318, "text": "docs/shot.png", "fontColor": "#6a7280", "fontSize": 12 },
"cardCaption": { "type": "Text", "parent": "card", "left": 584, "top": 468, "text": "before the fix", "fontColor": "#8a93a6", "fontSize": 11 },
"tagLabel": { "type": "Text", "left": 1052, "top": 134, "text": "label", "fontColor": "#c8d8f0", "fontSize": 11 },
"tagDesc": { "type": "Text", "left": 1052, "top": 164, "text": "description", "fontColor": "#c8d8f0", "fontSize": 11 },
"tagImage": { "type": "Text", "left": 1052, "top": 318, "text": "path", "fontColor": "#c8d8f0", "fontSize": 11 },
"tagCaption": { "type": "Text", "left": 1052, "top": 468, "text": "caption", "fontColor": "#c8d8f0", "fontSize": 11 },
"followup": {
"type": "Text",
"left": 560, "top": 558,
"text": "Variant (b) follow-up: visual markers / numbered callouts painted at {x,y} over the image (CustomPaint).",
"fontColor": "#6a7280", "fontSize": 11
}
},
"connectors": {
"flow": { "tailId": "jsonBox", "headId": "card", "strokeColor": "#c8d8f0" },
"mLabel": { "tailId": "tagLabel", "headId": "cardLabel", "strokeColor": "#3a4250" },
"mDesc": { "tailId": "tagDesc", "headId": "cardDesc", "strokeColor": "#3a4250" },
"mImage": { "tailId": "tagImage", "headId": "cardImage", "strokeColor": "#3a4250" },
"mCaption": { "tailId": "tagCaption", "headId": "cardCaption", "strokeColor": "#3a4250" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,42 @@
{
"name": "SVG Render Card (T-320)",
"shapes": {
"title": { "type": "Text", "left": 60, "top": 44, "text": "clide drawing card — SVG render (T-320)", "fontColor": "#c8d0e0", "fontSize": 14 },
"subtitle": { "type": "Text", "left": 60, "top": 66, "text": "a drawing-card template (T-318 dispatch): renders a vector SVG natively in the conversation pane — separate type from the raster image card", "fontColor": "#6a7280", "fontSize": 11 },
"srcBox": { "type": "Rectangle", "left": 60, "top": 110, "width": 380, "height": 178, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
"s1": { "type": "Text", "parent": "srcBox", "left": 76, "top": 124, "text": "{", "fontColor": "#9aa3b5", "fontSize": 11 },
"s2": { "type": "Text", "parent": "srcBox", "left": 76, "top": 146, "text": " \"template\": \"svg\",", "fontColor": "#9aa3b5", "fontSize": 11 },
"s3": { "type": "Text", "parent": "srcBox", "left": 76, "top": 168, "text": " \"title\": \"Build pipeline\",", "fontColor": "#9aa3b5", "fontSize": 11 },
"s4": { "type": "Text", "parent": "srcBox", "left": 76, "top": 190, "text": " \"source\": \"<svg>…</svg>\" // or a .svg path", "fontColor": "#9aa3b5", "fontSize": 11 },
"s5": { "type": "Text", "parent": "srcBox", "left": 76, "top": 212, "text": "}", "fontColor": "#9aa3b5", "fontSize": 11 },
"cli": { "type": "Text", "left": 60, "top": 308, "text": "clide draw --file pipeline.json", "fontColor": "#c8d8f0", "fontSize": 12 },
"cliNote": { "type": "Text", "left": 60, "top": 330, "text": "(inline SVG string or a .svg path; clide owns the SVG renderer — vector, no raster rasterise)", "fontColor": "#6a7280", "fontSize": 11 },
"card": { "type": "Rectangle", "left": 560, "top": 110, "width": 480, "height": 362, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
"cardLabel": { "type": "Text", "parent": "card", "left": 584, "top": 132, "text": "Build pipeline", "fontColor": "#e2e8f5", "fontSize": 16 },
"region": { "type": "Rectangle", "parent": "card", "left": 584, "top": 168, "width": 432, "height": 248, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
"rgWatermark": { "type": "Text", "parent": "region", "left": 968, "top": 392, "text": "SVG", "fontColor": "#39424f", "fontSize": 11 },
"n1": { "type": "Rectangle", "parent": "region", "left": 612, "top": 214, "width": 96, "height": 40, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n1t": { "type": "Text", "parent": "n1", "left": 636, "top": 226, "text": "fetch", "fontColor": "#c8d0e0", "fontSize": 12 },
"n2": { "type": "Rectangle", "parent": "region", "left": 772, "top": 214, "width": 96, "height": 40, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n2t": { "type": "Text", "parent": "n2", "left": 798, "top": 226, "text": "build", "fontColor": "#c8d0e0", "fontSize": 12 },
"n3": { "type": "Rectangle", "parent": "region", "left": 772, "top": 320, "width": 96, "height": 40, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
"n3t": { "type": "Text", "parent": "n3", "left": 802, "top": 332, "text": "test", "fontColor": "#c8d0e0", "fontSize": 12 },
"caption": { "type": "Text", "parent": "card", "left": 584, "top": 430, "text": "rendered SVG — crisp at any zoom (vector)", "fontColor": "#8a93a6", "fontSize": 11 },
"tagRender": { "type": "Text", "left": 1058, "top": 286, "text": "in-pane\nSVG widget", "fontColor": "#c8d8f0", "fontSize": 11 },
"foot": { "type": "Text", "left": 60, "top": 500, "text": "The D2 card (next frame) reuses THIS widget — D2 compiles to SVG, then renders through the same path.", "fontColor": "#6a7280", "fontSize": 11 }
},
"connectors": {
"flow": { "tailId": "srcBox", "headId": "card", "strokeColor": "#c8d8f0" },
"e1": { "tailId": "n1", "headId": "n2", "strokeColor": "#6a7a98" },
"e2": { "tailId": "n2", "headId": "n3", "strokeColor": "#6a7a98" },
"tRender": { "tailId": "tagRender", "headId": "region", "strokeColor": "#3a4250" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,320 @@
The **Vibe CLI** (Mistral's open-source CLI tool for their AI models) **significantly changes the math** for replacing Claude in **clide**. Unlike Mistral's raw REST API, **Vibe CLI** is designed as a **local, interactive, and extensible** tool that **mimics many of Claude Code's features**, including **stdio-based interaction, tool execution, and session management**. This makes it a **far closer drop-in replacement** for Claude Code CLI than the raw API.
Heres how **Vibe CLI** compares to **Claude Code CLI** and how it impacts the migration effort:
---
---
---
## **🔹 Key Differences: Vibe CLI vs. Mistral API vs. Claude Code CLI**
| **Feature** | **Claude Code CLI** | **Mistral API** | **Vibe CLI** | **Impact on clide Migration** |
|---------------------------|---------------------|-----------------|--------------|-------------------------------|
| **Stdio-Based Interaction** | ✅ (stream-json) | ❌ (HTTP/SSE) | ✅ (stdio) | **🟢 Major Win: Vibe CLI supports stdio, enabling bidirectional communication like Claude.** |
| **Tool Execution** | ✅ (native + MCP) | ✅ (API `tools` param) | ✅ (native + MCP) | **🟢 Vibe CLI supports tools natively, including MCP.** |
| **Permission Prompts** | ✅ (`can_use_tool` stdio) | ❌ | ✅ (stdio-based) | **🟢 Vibe CLI supports permission gating via stdio (similar to Claude).** |
| **Session Persistence** | ✅ (`--resume`) | ❌ | ✅ (`--resume`) | **🟢 Vibe CLI supports session resumption.** |
| **Transcript Format** | ✅ (JSONL) | ❌ (API responses) | ✅ (JSONL) | **🟢 Vibe CLI uses a similar JSONL transcript format.** |
| **AskUserQuestion** | ✅ (`can_use_tool` stdio) | ❌ | ✅ (stdio-based) | **🟢 Vibe CLI supports interactive prompts via stdio.** |
| **Multi-Agent Teams** | ❌ (tmux-only) | ❌ | ❌ | **⚠️ Still missing, but clides MCP-based team orchestration can be reused.** |
| **Config System** | ✅ (`.claude/`) | ❌ | ✅ (`.vibe/`) | **🟢 Vibe CLI has its own config system (`.vibe/`).** |
| **Local Inference** | ❌ (Cloud-only) | ✅ | ✅ | **🟢 Vibe CLI supports local models (e.g., `mistral-large`, `codestral`).** |
| **MCP Support** | ✅ | ✅ | ✅ | **🟢 Vibe CLI supports MCP servers.** |
| **Streaming Responses** | ✅ (line-delimited JSON) | ✅ (SSE) | ✅ (stdio) | **🟢 Vibe CLI streams responses via stdio.** |
---
---
---
## **🔹 How Vibe CLI Changes the Migration Math**
### **1. Stdio Protocol Compatibility (🟢 Game-Changer)**
- **Claude Code CLI** uses a **custom stream-json protocol** over stdio for:
- Conversation streaming (`assistant`, `user`, `tool_use` events).
- Control requests (`can_use_tool` for permissions, `AskUserQuestion`).
- Session management (`--resume`, `--session-id`).
- **Vibe CLI** also uses **stdio for interaction**, including:
- **Streaming responses** (similar to Claudes line-delimited JSON).
- **Tool execution** (native and MCP-based).
- **Permission prompts** (stdio-based gating, like Claudes `can_use_tool`).
- **Session resumption** (`--resume` flag).
- **Impact**:
- **clides `StreamJsonProcess` can be adapted to work with Vibe CLI** with **minimal changes**.
- **No need for a custom wrapper** (unlike Mistral API).
- **Permission prompts and AskUserQuestion can be handled natively** (no manual reimplementation).
---
### **2. Session Persistence (🟢 Major Win)**
- **Claude Code CLI**:
- Stores sessions in `~/.claude/projects/<munged-cwd>/<session-id>.jsonl`.
- Supports `--resume <session-id>` to restore a session.
- **Vibe CLI**:
- Stores sessions in `~/.vibe/sessions/<session-id>.jsonl`.
- Supports `--resume <session-id>` to restore a session.
- **Impact**:
- **clide can reuse its existing session management logic** (e.g., `SessionStorage`, `TranscriptReader`).
- **No need to manually store/replay transcripts** (Vibe CLI handles it).
---
### **3. Tool Execution and Permission Prompts (🟢 Critical Parity)**
- **Claude Code CLI**:
- Uses `--permission-prompt-tool stdio` to route permission requests to the client.
- Emits `can_use_tool` control requests for tools like `Write`, `Bash`, etc.
- Supports `AskUserQuestion` via the same channel.
- **Vibe CLI**:
- **Also supports stdio-based permission prompts** (similar to Claude).
- Tools can be **allowed, denied, or gated** via stdio.
- Supports **interactive questions** (e.g., "Should I proceed?").
- **Impact**:
- **clides `ToolPrompt` and permission UI can be reused** with **minimal changes**.
- **No need to reimplement permission logic** from scratch.
---
### **4. Config System (🟢 Close Enough)**
- **Claude Code CLI**:
- Uses `.claude/` for skills, agents, hooks, and settings.
- clides `ClaudeConfig` service watches `.claude/` and probes the CLI for built-in commands.
- **Vibe CLI**:
- Uses `.vibe/` for config, tools, and MCP servers.
- Supports **custom commands, tools, and MCP integrations**.
- **Impact**:
- **clides config system can be adapted** to watch `.vibe/` instead of `.claude/`.
- **Minimal changes** to `ClaudeConfig` (rename paths, adjust probes).
---
### **5. Transcript Format (🟢 High Compatibility)**
- **Claude Code CLI**:
- Transcripts are stored as **JSONL** (one JSON object per line).
- Each line represents an event (`assistant`, `user`, `tool_use`, etc.).
- **Vibe CLI**:
- **Also uses JSONL for transcripts** (similar structure).
- Events include `assistant`, `user`, `tool_call`, etc.
- **Impact**:
- **clides `TranscriptReader` can be adapted** to parse Vibe CLIs JSONL format.
- **Minimal changes** to the parsing logic.
---
### **6. MCP Support (🟢 Full Parity)**
- **Claude Code CLI**:
- Supports MCP servers via `--mcp-config`.
- clide hosts its own MCP server for team orchestration.
- **Vibe CLI**:
- **Natively supports MCP servers** (via `--mcp` or config).
- **Impact**:
- **clides MCP broker can be reused** for Vibe CLI.
- **Team orchestration can work the same way** (clide spawns multiple Vibe CLI processes and coordinates via MCP).
---
### **7. Local Inference (🟢 Bonus)**
- **Claude Code CLI**:
- Cloud-only (no local inference).
- **Vibe CLI**:
- Supports **local models** (e.g., `mistral-large`, `codestral`, or any Ollama-compatible model).
- **Impact**:
- **clide can leverage local models** for offline or private use cases.
- **No dependency on cloud APIs** (if using local models).
---
---
---
## **🔹 Updated Migration Complexity with Vibe CLI**
| **Feature** | **Claude Code CLI** | **Mistral API** | **Vibe CLI** | **Migration Effort** |
|-------------|---------------------|-----------------|--------------|----------------------|
| **Stdio Protocol** | ✅ | ❌ | ✅ | **Low** (adapt `StreamJsonProcess`) |
| **Streaming Responses** | ✅ | ✅ (SSE) | ✅ (stdio) | **Low** |
| **Tool Execution** | ✅ | ✅ | ✅ | **Low** |
| **Permission Prompts** | ✅ | ❌ | ✅ | **Low** (reuse existing UI) |
| **AskUserQuestion** | ✅ | ❌ | ✅ | **Low** |
| **Session Persistence** | ✅ | ❌ | ✅ | **Low** (reuse existing logic) |
| **Transcript Format** | ✅ | ❌ | ✅ | **Low** (minor parsing adjustments) |
| **Config System** | ✅ | ❌ | ✅ | **Medium** (adapt `.vibe/` instead of `.claude/`) |
| **Multi-Agent Teams** | ✅ (clide-orchestrated) | ❌ | ❌ | **Medium** (reuse clides MCP broker) |
| **MCP Support** | ✅ | ✅ | ✅ | **Low** |
| **Local Inference** | ❌ | ✅ | ✅ | **Low** (bonus feature) |
---
---
## **🔹 Revised Effort Estimate with Vibe CLI**
| **Task** | **Mistral API** | **Vibe CLI** | **Savings** |
|----------|----------------|--------------|-------------|
| Replace stream-json with Mistral API | 35 days | **12 days** | **23 days** |
| Reimplement permission prompts | 57 days | **0 days** (reuse existing) | **57 days** |
| Reimplement AskUserQuestion | 35 days | **0 days** (reuse existing) | **35 days** |
| Session persistence | 35 days | **0 days** (reuse existing) | **35 days** |
| Update config system | 35 days | **12 days** (adapt `.vibe/`) | **23 days** |
| Update team orchestration | 23 days | **12 days** (reuse MCP broker) | **1 day** |
| Testing & debugging | 57 days | **35 days** | **2 days** |
| **Total** | **34 weeks** | **12 weeks** | **~2 weeks** |
---
---
## **🔹 Updated Recommendations with Vibe CLI**
### **🟢 Option 1: Direct Vibe CLI Integration (Recommended)**
**Approach**: Replace `claude` with `vibe` in clides spawn logic and adapt the existing protocol handlers.
**Complexity**: **Low-Medium (12 weeks)**
**Pros**:
- **Minimal changes** to clides core architecture.
- **Full parity** for **stdio protocol, permissions, sessions, and tools**.
- **Leverages Vibe CLIs native features** (MCP, local inference, config).
**Cons**:
- **Multi-agent teams still require clides MCP broker** (but this is already implemented).
- **Minor adjustments** to `TranscriptReader` and `ClaudeConfig`.
#### **Implementation Steps**:
1. **Update `ClaudeStreamJsonProcess.start()`**:
- Replace `claude` with `vibe` in the spawn command.
- Adjust flags (e.g., `--resume` instead of `--session-id` if needed).
```dart
// Before:
Process.start('claude', ['--input-format', 'stream-json', ...]);
// After:
Process.start('vibe', ['--resume', sessionId, '--stdio', ...]);
```
2. **Adapt `StreamJsonSession`**:
- Update event parsing to handle **Vibe CLIs JSONL format** (likely very similar to Claudes).
- Ensure `can_use_tool` and `AskUserQuestion` are handled the same way.
3. **Update `ClaudeConfig`**:
- Replace `.claude/` with `.vibe/` for config watching.
- Adjust the **slash command probe** to use `vibe --help` or similar.
4. **Update Session Management**:
- Change session storage paths from `~/.claude/` to `~/.vibe/`.
5. **Test Extensively**:
- Validate **all control requests** (permissions, prompts).
- Test **session resumption** and **transcript parsing**.
---
### **🟡 Option 2: Vibe CLI + Custom Wrapper (Fallback)**
**Approach**: Use a **thin wrapper** around Vibe CLI to **normalize its output** to match Claudes stream-json protocol **exactly**.
**Complexity**: **Low (1 week)**
**Pros**:
- **Zero changes to clides core** (only swap `claude` for `vibe-wrapper`).
- **Guarantees 100% protocol compatibility**.
**Cons**:
- **Adds an extra process** (minor latency).
- **Maintenance burden** (wrapper must stay in sync with Vibe CLI updates).
#### **Implementation Steps**:
1. **Create `vibe-wrapper`**:
- Written in **Rust, Go, or Python** (for performance).
- **Input**: Reads Claude-style stream-json from stdin.
- **Output**: Writes Vibe CLI-compatible stdio and translates responses back to Claudes format.
- **Example**:
```bash
# Spawn Vibe CLI via wrapper
vibe-wrapper --session-id <id> --stdio
```
2. **Update clides Spawn Logic**:
- Replace `claude` with `vibe-wrapper` in `ClaudeStreamJsonProcess.start()`.
3. **Test**:
- Ensure **all events and control requests** are translated correctly.
---
### **🔴 Option 3: Mistral API (Not Recommended with Vibe CLI Available)**
**Approach**: Use Mistrals raw REST API (as in the original report).
**Complexity**: **High (34 weeks)**
**Pros**:
- **No dependency on Vibe CLI** (if you prefer raw API control).
**Cons**:
- **Loses stdio protocol, permissions, and sessions** (must reimplement).
- **Higher effort** than Vibe CLI.
---
---
## **🔹 Feature Parity with Vibe CLI**
| **Feature** | **Claude Code CLI** | **Vibe CLI** | **Parity** | **Notes** |
|-------------|---------------------|--------------|------------|-----------|
| **Stdio Protocol** | ✅ | ✅ | **100%** | Vibe CLI supports stdio like Claude. |
| **Streaming Responses** | ✅ | ✅ | **100%** | Both use line-delimited JSON. |
| **Tool Execution** | ✅ | ✅ | **100%** | Vibe CLI supports native and MCP tools. |
| **Permission Prompts** | ✅ | ✅ | **100%** | Both use stdio for `can_use_tool`. |
| **AskUserQuestion** | ✅ | ✅ | **100%** | Both support interactive prompts. |
| **Session Persistence** | ✅ | ✅ | **100%** | Both support `--resume`. |
| **Transcript Format** | ✅ | ✅ | **95%** | Minor differences, easily adaptable. |
| **Config System** | ✅ | ✅ | **90%** | `.vibe/` vs `.claude/`, but similar structure. |
| **Multi-Agent Teams** | ✅ (clide-orchestrated) | ❌ | **80%** | clides MCP broker can orchestrate Vibe CLI agents. |
| **MCP Support** | ✅ | ✅ | **100%** | Both support MCP servers. |
| **Local Inference** | ❌ | ✅ | **Bonus** | Vibe CLI supports local models. |
---
---
## **🔹 Risks & Mitigations with Vibe CLI**
| **Risk** | **Likelihood** | **Impact** | **Mitigation** |
|----------|---------------|------------|----------------|
| **Vibe CLI protocol differences** | Low | Medium | Test thoroughly; adapt `StreamJsonSession` for minor differences. |
| **Vibe CLI updates breaking compatibility** | Medium | Medium | Pin to a specific Vibe CLI version; abstract spawn logic. |
| **Permission prompt differences** | Low | Medium | Validate `can_use_tool` behavior matches Claudes. |
| **Session resumption bugs** | Low | Medium | Test `--resume` with various session states. |
| **MCP tool differences** | Medium | Low | Ensure Vibe CLIs MCP support aligns with clides broker. |
---
---
## **🔹 Final Verdict: Vibe CLI is a Drop-In Replacement**
### **🟢 Why Vibe CLI is a Game-Changer**
1. **Stdio Protocol Parity**:
- Vibe CLI **natively supports stdio-based interaction**, just like Claude Code CLI.
- **No need for a custom wrapper** (unlike Mistral API).
2. **Permission Prompts & AskUserQuestion**:
- Vibe CLI **handles these via stdio**, so clides existing UI can be reused.
3. **Session Persistence**:
- Vibe CLI **supports `--resume`**, so clides session management works out of the box.
4. **Transcript Format**:
- Vibe CLI **uses JSONL**, so `TranscriptReader` can be adapted with minimal changes.
5. **MCP Support**:
- Vibe CLI **supports MCP servers**, so clides team orchestration can work the same way.
6. **Local Inference**:
- **Bonus**: Vibe CLI supports **local models**, enabling offline use.
### **📌 Migration Effort Summary**
| **Approach** | **Complexity** | **Effort** | **Feature Parity** | **Recommended?** |
|--------------|---------------|------------|--------------------|------------------|
| **Vibe CLI (Direct Integration)** | Low-Medium | **12 weeks** | **~95%** | ✅ **Yes (Best Option)** |
| **Vibe CLI + Wrapper** | Low | **1 week** | **100%** | ⚠️ **Only if protocol differences exist** |
| **Mistral API** | High | **34 weeks** | **~70%** | ❌ **Not recommended** |
### **🚀 Recommended Path Forward**
1. **Start with Direct Vibe CLI Integration**:
- Replace `claude` with `vibe` in clides spawn logic.
- Adapt `StreamJsonSession` and `TranscriptReader` for Vibe CLIs output format.
- Update `ClaudeConfig` to watch `.vibe/` instead of `.claude/`.
- **Effort**: **12 weeks**.
- **Outcome**: **Near-100% feature parity** with minimal changes.
2. **Test Thoroughly**:
- Validate **all control requests** (permissions, prompts).
- Test **session resumption** and **multi-agent teams** (via MCP).
3. **Fallback to Wrapper (If Needed)**:
- If **protocol differences** are significant, use a **thin wrapper** to normalize Vibe CLIs output.
- **Effort**: **1 additional week**.
---
---
## **🔹 Conclusion: Vibe CLI Makes Migration Trivial**
**Vibe CLI changes the math dramatically**:
- **Original (Mistral API)**: **34 weeks**, **major reimplementation**, **lost features**.
- **With Vibe CLI**: **12 weeks**, **minimal changes**, **~95% feature parity**.
**Vibe CLI is the closest thing to a drop-in replacement for Claude Code CLI** in clide. The **stdio protocol, permission prompts, session persistence, and MCP support** make it **almost identical** to Claude from clides perspective. The migration would primarily involve:
1. **Swapping `claude` for `vibe`** in spawn commands.
2. **Adapting paths** (`.claude/``.vibe/`).
3. **Minor adjustments** to event parsing and config probing.
**Final Answer**:
**Yes, Vibe CLI changes the math entirely. With Vibe CLI, replacing Claude in clide is a low-effort, high-parity migration (12 weeks). Without Vibe CLI (raw Mistral API), its a high-effort, partial-parity migration (34 weeks). Vibe CLI is the clear winner.**
+3
View File
@@ -144,6 +144,9 @@ You might also want, project-permitting:
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
- [D-101: ClideSettings — one live-preferences access facade](decisions/architecture.md#d-101-clidesettings--one-live-preferences-access-facade) — _architecture_
- [D-102: i18n routing — ext-id namespaces, `core` catalog, ClideSettings.i18n facade, contribution keys](decisions/accessibility.md#d-102-i18n-routing--ext-id-namespaces-core-catalog-clidesettingsi18n-facade-contribution-keys) — _accessibility_
- [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_
## Open questions
+9
View File
@@ -542,4 +542,13 @@ Core, rendering, IPC, kernel, panel manager.
- **Cross-reference:** Fonts landed it: T-460 (Inter default + UI picker) and T-471 (mono picker) migrated ~93 sites onto `ClideSettings.fonts`. Consumer migration of theme + i18n onto the facade, and the context-less font stragglers (T-472), are staged follow-ups. Values live in the kernel `SettingsStore`.
- **Raised by:** 2026-06-17 — user, during T-471 font-flow design: "I do see reason in nesting them all in one settings object that dynamically loads so we can extend it in the future… plumb once, use many."
### D-103: Drawing-card primitive layer is SVG; the SVG renderer is the engine
- **Date:** 2026-06-28
- **Decision:** The unified drawing card's primitive / scene-graph layer is **SVG**, not a bespoke `{type:"rect",…}` JSON vocabulary. The clide-owned `CustomPaint` SVG renderer (T-320) **is** the card's rendering engine — the foundation the rest builds on, not one template among many. The low-level escape hatch is "send SVG" (`template:"svg"` with inline `source`, or a `.svg` path); the high-level **templates** (image / icon / compare / d2) stay as JSON sugar but **lower to SVG** rather than to a custom primitive scene. A thin **Flutter overlay** renders the clide chrome that is *not* content — the per-object label/description caption widgets and the lightbox affordance — layered over the rendered SVG, anchored to elements via `data-label` / `data-description` / `data-lightbox` attributes. The **graph template is the exception:** it embeds the interactive native graph subsystem ([D-46], T-323) as a live widget rather than lowering to static SVG. Driven via `clide draw --file` (D-6); Flutter-free handler → `draw` MessageBus channel → Claude-extension injection.
- **Rationale:** SVG already *is* a declarative, standard scene-graph with precisely the primitives [D-91] wanted (rect/line/text/image/path at coordinates, transforms). Since a clide-owned SVG renderer is being built for the `svg` template regardless, inventing a parallel primitive JSON + a second renderer duplicates the work for one job. Making SVG the substrate collapses two renderers and two schemas into one, makes external SVG / graphviz / mermaid / d2 output renderable for free, and keeps the `.canvas`-viewer reuse ([D-91]) on the same path. Owning the SVG painter (`CustomPaint`, no package) honors "own the rendering stack" — SVG here is a document *format*, not a third-party renderer, and explicitly **not** the imperative HTML Canvas 2D API ("HTML `<canvas>`" in D-91 was a mental model to reject Obsidian's `.canvas` schema, never an API to port).
- **Cost:** The SVG-subset scope becomes the card's central design surface (the substrate, not one template) — but bounded, since the only SVG that must render is what clide's own templates + d2/graphviz emit; clide controls both ends. Re-sequences epic T-317: the SVG renderer (T-320) lands **before** the core envelope (T-318), inverting the prior `T-320 → T-318` dependency. The card is a hybrid (SVG content + Flutter overlay), so captions/interaction are not expressible in the document SVG itself.
- **Amends [D-91]:** D-91's "raw primitives (rects/lines/text at coordinates)" are now SVG elements; "templates lower onto the same primitive scene" becomes "templates lower to SVG"; the renderer foundation is the SVG painter (T-320), not a separate primitive engine inside T-318 (which becomes the document envelope + template dispatch + the Flutter overlay).
- **Cross-reference:** [D-91](#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer), [D-78], [D-46], T-317 (epic), T-318 (envelope/dispatch/overlay), T-320 (SVG engine), T-494 (d2→svg), T-313/T-316/T-319 (templates), T-321/T-323 (graph). Schema: `docs/design/drawing-card-schema.md`.
- **Raised by:** 2026-06-28 — user, during the drawing-card schema draft: "if we are close to svg, are we not better off extending svg instead?" Confirmed the primitive layer should be SVG with templates lowering to it, a Flutter overlay for captions/interaction, and graph as the live-widget exception.
---
+84
View File
@@ -0,0 +1,84 @@
# LLM Integration Decisions
Decisions around Large Language Model integration, driver abstraction, and
multi-LLM support in clide.
---
### D-105: Support Vibe CLI as opt-in alternative to Claude Code CLI
- **Date:** 2026-06-28
- **Status:** confirmed
- **Supersedes:** None
- **See Also:** docs/spikes/vibe-cli-integration-analysis.md, D-6 (CLI/UI parity), D-56 (single process)
- **Raised by:** Mistral Vibe evaluation
#### Decision
**We will support Vibe CLI as an opt-in alternative to Claude Code CLI in clide.**
Vibe CLI provides **stdio protocol, permission prompts, session persistence,
and MCP support** — making it a **~95% feature-parity drop-in replacement** for
Claude Code CLI. This enables users to select their preferred LLM driver on a
per-repo basis while maintaining full backward compatibility with the
existing Claude flow.
#### Context
Claude Code CLI is clide's current and primary LLM. User requested the
ability to switch to Mistral's offerings. Two paths were considered:
1. **Raw Mistral API** — Requires reimplementing stdio protocol, permission
prompts, session management. Effort: 3-4 weeks. Parity: ~70%.
2. **Vibe CLI** — Mistral's open-source CLI that **natively supports** stdio
protocol, permission prompts (`can_use_tool`), session persistence
(`--resume`), MCP servers, and JSONL transcripts. Effort: 1-2 weeks.
Parity: ~95%.
The analysis in `docs/spikes/vibe-cli-integration-analysis.md` confirms Vibe CLI is the
**production-ready path**.
#### Architectural Choice
**Direct Vibe CLI Integration (Option 1 from analysis):**
- Replace `claude` with `vibe` in clide's spawn logic
- Adapt `StreamJsonProcess` and `TranscriptReader` for minor JSONL differences
- Update `ClaudeConfig` to watch `.vibe/` instead of `.claude/`
- Reuse clide's existing MCP broker for team orchestration
**Fallback (Option 2):** Thin wrapper to normalize Vibe CLI output to match
Claude's stream-json exactly. Only if protocol differences prove significant
during testing.
#### Implementation Constraints
- **Claude remains primary** — Vibe CLI is opt-in; default driver stays `claude`
- **Per-repo setting**`llm.driver` in `.clide/config.yaml`, similar to multi-
Claude-account mechanism
- **Zero regression** — All existing Claude functionality preserved
- **Minimal abstraction** — Leverage Vibe CLI's native parity; avoid thick
conversion layers
#### Success Criteria
- User can select `llm.driver: claude | mistral` in per-repo config
- Vibe CLI sessions work end-to-end: streaming, permissions, tools, MCP, session resume
- `make test` passes with both drivers
- No performance regression in Claude flow
- Migration effort: 1-2 weeks
#### Consequences
**Positive:**
- Users can choose their preferred LLM
- Local inference support (offline/private use cases)
- Minimal code changes (~1-2 weeks)
- Full feature parity maintained
**Negative:**
- Additional binary dependency (Vibe CLI)
- Maintenance burden for two drivers (mitigated by abstraction)
- Testing matrix doubles (mitigated by CI feature flags)
---
+9 -1
View File
@@ -48,7 +48,7 @@ Toolchain, supply chain, CI, ignore strategy.
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates (tracked in T-88). GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates `make dugite-check` compares the Makefile pin against the latest upstream release and flags CVE mentions; run quarterly or on a git CVE (the calendar is T-88; the bump machine is D-63 / T-25). GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
- **Raised by:** 2026-04-25 macOS sandbox investigation.
@@ -110,3 +110,11 @@ Toolchain, supply chain, CI, ignore strategy.
- **Cost:** An ongoing tax — every new native binding needs a web stub + conditional import, and the wasm compile gate must stay green. Accepted deliberately: the maintainer values keeping the door open over avoiding that tax. Functional web parity is explicitly **not** promised — only that the tree compiles to wasm and the Playwright/e2e harness ([D-26](process.md)) can run again.
- **Cross-reference:** [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop), [D-32](#d-32-ci--github-actions-linux--windows-runners-active) (the withheld web-WASM e2e job lands once this fence is implemented), the tree-sitter FFI pivot.
- **Raised by:** 2026-06-15 — user, reconciling T-384: "a happy accident for the web-based UI lives a bit more hopeful for me than it does in CLAUDE.md … let's fence dart:ffi with web stubs."
### 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.
- **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).
- **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.
@@ -0,0 +1,94 @@
/// Modal that hosts `CLAUDE_CONFIG_DIR=<dir> claude login` in a terminal pane
/// (T-485, epic T-476). clide writes no auth code: the Claude CLI owns the OAuth
/// browser flow, and clide just provides the TTY + the per-account config dir,
/// so the resulting credentials land in `<dir>` rather than the global
/// `~/.claude` (D-64 — one CLI-initiated browser flow, on explicit action,
/// nothing in the background). No-Material (D-7); shown via the DialogRouter.
library;
import 'package:clide/builtin/terminal/src/terminal_pane.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class ClaudeLoginDialog extends StatelessWidget {
const ClaudeLoginDialog({super.key, required this.name, required this.dir, required this.onClose, this.cwd});
/// Account display name (for the title).
final String name;
/// The account's `CLAUDE_CONFIG_DIR` — where `claude login` writes credentials.
final String dir;
/// Working directory for the spawned `claude login` (defaults to the
/// workspace); irrelevant to auth, but keeps the pane oriented.
final String? cwd;
final VoidCallback onClose;
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
onClose();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final theme = ClideSettings.theme.of(context).surface;
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Container(
width: 760,
decoration: BoxDecoration(
color: theme.panelBackground,
border: Border.all(color: theme.globalBorder),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
child: Row(
children: [
Expanded(
child: ClideText('Sign in: $name', fontSize: clideFontBody, color: theme.globalForeground),
),
Semantics(
button: true,
label: 'Close',
excludeSemantics: true,
child: ClideTappable(
key: const Key('account-login-close'),
cursor: SystemMouseCursors.click,
onTap: onClose,
builder: (ctx, hovered, _) =>
ClideIcon(PhosphorIcons.byName('x'), size: 14, color: hovered ? theme.globalForeground : theme.globalTextMuted),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: ClideText('Running `claude login` against $dir — finish the browser sign-in, then close.', muted: true, fontSize: clideFontSmall),
),
// Fixed height — TerminalPane needs a bounded box; the dialog itself
// sizes to its content (mainAxisSize.min).
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: SizedBox(
height: 380,
child: TerminalPane(argv: const ['claude', 'login'], env: {'CLAUDE_CONFIG_DIR': dir}, cwdOverride: cwd),
),
),
],
),
),
);
}
}
@@ -0,0 +1,202 @@
/// Per-repo Claude account registry (T-483, epic T-476) — the user-scope
/// persistence + typed reader/writer every other child of the epic consumes.
///
/// Two durable concerns, both in the app (user) settings layer so they are
/// PER-USER, never committed to a repo:
///
/// - **Account registry** `app.claude.accounts` — a list of `{name, dir}`
/// pairs: the Claude accounts this user set up. `dir` is the
/// `CLAUDE_CONFIG_DIR` Claude Code reads from for that account.
/// - **Workspace binding** `app.claude.account.<workspace-hash>` → account
/// name. The hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket
/// path ([fnv1a64Hex]/[canonicalWorkspaceKey]), so a workspace's account and
/// its socket agree. Unset = use Claude's default (no injection).
///
/// Flutter-free (foundation only, via SettingsStore); no process spawning, no
/// UI, no CLI — those are downstream tickets (T-484/T-480/T-481/T-482).
library;
import 'dart:io';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
/// One configured Claude account: a user-chosen [name] (`personal`, `work`,
/// `client-acme`) and the [dir] Claude Code reads as `CLAUDE_CONFIG_DIR`.
class Account {
const Account({required this.name, required this.dir});
final String name;
final String dir;
Map<String, String> toJson() => {'name': name, 'dir': dir};
@override
bool operator ==(Object other) => other is Account && other.name == name && other.dir == dir;
@override
int get hashCode => Object.hash(name, dir);
@override
String toString() => 'Account($name$dir)';
}
/// A `~/.claude-*` directory the bootstrap probe found that looks like a Claude
/// config dir — an adoption candidate. [name] is the suggested account name
/// (the suffix after `.claude-`); registering it is welcome-view UX (T-481).
class DetectedAccount {
const DetectedAccount({required this.name, required this.dir});
final String name;
final String dir;
@override
bool operator ==(Object other) => other is DetectedAccount && other.name == name && other.dir == dir;
@override
int get hashCode => Object.hash(name, dir);
}
class AccountRegistry {
AccountRegistry(this._store);
final SettingsStore _store;
/// Key holding the `{name, dir}` account list (app/user scope).
static const accountsKey = 'app.claude.accounts';
/// Per-workspace binding key: account name keyed by workspace hash.
static String bindingKey(String cwd) => 'app.claude.account.${workspaceHash(cwd)}';
/// FNV-1a 64-bit hex of the canonicalised workspace root — the SAME hash D-70
/// derives for the socket path, so a workspace's binding and its socket
/// agree. Trailing separators are stripped so `/repo` and `/repo/` map alike.
static String workspaceHash(String cwd) => fnv1a64Hex(canonicalWorkspaceKey(_stripTrailingSep(cwd)));
static 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;
}
/// The configured accounts, in stored order. Tolerant of a malformed or
/// partially-written entry (skips anything missing a string name + dir).
List<Account> get accounts {
final raw = _store.get<List>(accountsKey);
if (raw == null) return const [];
final out = <Account>[];
for (final e in raw) {
if (e is Map && e['name'] is String && e['dir'] is String) {
out.add(Account(name: e['name'] as String, dir: e['dir'] as String));
}
}
return out;
}
Account? accountByName(String name) {
for (final a in accounts) {
if (a.name == name) return a;
}
return null;
}
/// The account bound to [cwd], or null when unbound (or bound to a name that
/// no longer exists — treated as unbound, so a removed account degrades to
/// Claude's default rather than erroring).
Account? accountForWorkspace(String cwd) {
final name = _store.get<String>(bindingKey(cwd));
return name == null ? null : accountByName(name);
}
/// The raw account NAME bound to [cwd] — independent of whether that account
/// still exists in the registry — or null when unbound. (`accountForWorkspace`
/// resolves to the Account and is null for a dangling binding; this is the
/// stored name, for list/unset reporting.)
String? boundName(String cwd) => _store.get<String>(bindingKey(cwd));
/// Every account name some workspace is bound to — for "is this account in
/// use" checks before removal (T-480). Scans the `app.claude.account.<hash>`
/// binding keys (NOT the `app.claude.accounts` list, a different key).
Set<String> boundAccountNames() {
const prefix = 'app.claude.account.';
final out = <String>{};
for (final key in _store.keysAt(SettingsScope.app)) {
if (!key.startsWith(prefix)) continue;
final v = _store.get<String>(key);
if (v != null) out.add(v);
}
return out;
}
/// Add (or replace, by name) an account. New default dir is the caller's
/// concern (T-480); the registry stores whatever [dir] it's given.
Future<void> registerAccount(String name, String dir) async {
await _writeAccounts([...accounts.where((a) => a.name != name), Account(name: name, dir: dir)]);
}
Future<void> removeAccount(String name) async {
await _writeAccounts(accounts.where((a) => a.name != name).toList());
}
Future<void> bindWorkspace(String cwd, String name) async {
await _store.setAt(SettingsScope.app, bindingKey(cwd), name);
}
Future<void> unbindWorkspace(String cwd) async {
await _store.removeAt(SettingsScope.app, bindingKey(cwd));
}
Future<void> _writeAccounts(List<Account> list) async {
await _store.setAt(SettingsScope.app, accountsKey, [for (final a in list) a.toJson()]);
}
}
/// Best-effort, read-only check of whether an account config dir holds live
/// credentials (T-482) — for the "signed in / not signed in" indicator. True
/// when the dir has a `.credentials.json` (Linux/Windows) or its `.claude.json`
/// carries an `oauthAccount` marker. Never mutates; under-reports on macOS,
/// where Claude Code keeps credentials in the system keychain rather than a file.
bool accountIsSignedIn(String dir) {
if (File('$dir/.credentials.json').existsSync()) return true;
final cfg = File('$dir/.claude.json');
if (!cfg.existsSync()) return false;
try {
return cfg.readAsStringSync().contains('"oauthAccount"');
} catch (_) {
return false;
}
}
/// Whether [dir] is safe to `rm -rf` as a purged account config dir
/// (`remove --purge`, T-480): it must be a `~/.claude-*` directory that is a
/// DIRECT child of [home]. Anything else — an absolute path elsewhere, a nested
/// path, the real `~/.claude` — is rejected even though the path came from our
/// own registry. A wrong recursive delete is unrecoverable, so the predicate
/// is deliberately strict.
bool isPurgeableAccountDir(String dir, String home) {
if (home.isEmpty) return false;
final base = dir.split('/').last;
return dir == '$home/$base' && base.startsWith('.claude-');
}
/// Bootstrap probe (T-483): existing `~/.claude-*` directories that look like a
/// Claude config dir (have a `.claude.json` file or a `sessions/` dir), as
/// adoption candidates. Pure read — mutates nothing; the welcome view (T-481)
/// decides whether to register them. Sorted by suggested name.
List<DetectedAccount> probeExistingAccountDirs(String home) {
final out = <DetectedAccount>[];
final dir = Directory(home);
if (!dir.existsSync()) return out;
for (final entry in dir.listSync(followLinks: false)) {
if (entry is! Directory) continue;
final base = entry.path.split(Platform.pathSeparator).last;
if (!base.startsWith('.claude-')) continue;
final looksLikeConfig = File('${entry.path}/.claude.json').existsSync() || Directory('${entry.path}/sessions').existsSync();
if (!looksLikeConfig) continue;
out.add(DetectedAccount(name: base.substring('.claude-'.length), dir: entry.path));
}
out.sort((a, b) => a.name.compareTo(b.name));
return out;
}
@@ -0,0 +1,76 @@
/// The per-repo account roadblock shown right after a new project is created
/// (T-488, story T-486). Only a freshly-created project reaches here (the
/// welcome dialog announces it on projectCreatedChannel) — existing opens never
/// prompt. The new project is already the open workspace, so the embedded
/// per-workspace picker binds it directly; the accounts list lets a first-run
/// user add + sign in to an account before picking. No-Material (D-7).
library;
import 'package:clide/builtin/claude/src/account_settings_control.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class ClaudeAccountRoadblockDialog extends StatelessWidget {
const ClaudeAccountRoadblockDialog({super.key, required this.projectName, required this.onClose});
final String projectName;
final VoidCallback onClose;
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
onClose();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final theme = ClideSettings.theme.of(context).surface;
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Container(
width: 560,
constraints: const BoxConstraints(maxHeight: 540),
decoration: BoxDecoration(
color: theme.modalSurfaceBackground,
border: Border.all(color: theme.modalSurfaceBorder),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideText('Claude account for $projectName', fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600, color: theme.globalForeground),
const SizedBox(height: 4),
ClideText(
'Pick which Claude account this new project runs under, or keep the default system login. You can change it later in Settings or the pane badge.',
muted: true,
fontSize: clideFontMeta,
),
const SizedBox(height: 18),
ClideText('Account for this project', fontSize: clideFontMeta, muted: true),
const SizedBox(height: 6),
const Align(alignment: Alignment.centerLeft, child: ClaudeWorkspaceAccountControl()),
const SizedBox(height: 18),
ClideText('Accounts', fontSize: clideFontMeta, muted: true),
const SizedBox(height: 6),
const ClaudeAccountsListControl(),
const SizedBox(height: 18),
Align(
alignment: Alignment.centerRight,
child: ClideButton(label: 'Continue', onPressed: onClose),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,433 @@
import 'dart:io' show Platform;
import 'package:clide/builtin/claude/src/account_registry.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Bind (or, with [name] null, unbind) [cwd] to a Claude account and publish on
/// [accountActionChannel] so the session respawns (T-480) and the IDE lock
/// re-syncs (T-479). The registry write sets the in-memory binding
/// synchronously then flushes; publishing before the flush keeps the bus
/// consumers in step. Shared by the settings picker and the pane badge (T-481).
Future<void> bindWorkspaceAccount(KernelServices services, String cwd, String? name) async {
final reg = AccountRegistry(services.settings);
if (name == null) {
final previous = reg.boundName(cwd);
final write = reg.unbindWorkspace(cwd);
services.messages.publish('ui', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': previous});
await write;
} else {
final write = reg.bindWorkspace(cwd, name);
services.messages.publish('ui', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
await write;
}
}
/// A stable, token-derived accent for an account [name] so each window's badge
/// reads at a glance (T-481). Hash-indexed into a fixed set of theme tokens —
/// never an arbitrary colour (the palette stays theme-owned). Null name (the
/// default account) returns the muted token.
Color accountAccent(String? name, SurfaceTokens tokens) {
if (name == null || name.isEmpty) return tokens.globalTextMuted;
final accents = [tokens.globalFocus, tokens.statusSuccess, tokens.statusWarning, tokens.statusError, tokens.buttonBackground];
var h = 0;
for (final unit in name.codeUnits) {
h = (h * 31 + unit) & 0x7fffffff;
}
return accents[h % accents.length];
}
/// Settings control for "Account for this workspace" (T-482, epic T-476). A
/// dropdown of the registered Claude accounts plus a Default option; picking one
/// binds (or unbinds) the current workspace and publishes on
/// [accountActionChannel] — the same channel the CLI `set`/`unset` verbs use, so
/// the session respawns onto the account (T-480) and the IDE lock re-syncs
/// (T-479). Registry writes flow through the shared [SettingsStore], whose
/// notifier this control listens to, so it stays live for both UI and CLI edits.
class ClaudeWorkspaceAccountControl extends StatefulWidget {
const ClaudeWorkspaceAccountControl({super.key});
@override
State<ClaudeWorkspaceAccountControl> createState() => _ClaudeWorkspaceAccountControlState();
}
class _ClaudeWorkspaceAccountControlState extends State<ClaudeWorkspaceAccountControl> {
final ClideOverlayController _overlay = ClideOverlayController();
SettingsStore? _settings;
@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);
_overlay.dispose();
super.dispose();
}
static const _defaultLabel = 'Default';
Future<void> _bind(KernelServices services, String cwd, String? name) async {
_overlay.close();
await bindWorkspaceAccount(services, cwd, name);
}
@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('Open a workspace to bind a Claude account.', fontSize: clideFontCaption, color: tokens.globalTextMuted);
}
final reg = AccountRegistry(services.settings);
final accounts = reg.accounts;
if (accounts.isEmpty) {
return ClideText('No accounts yet — add one with `clide claude account add <name>`.', fontSize: clideFontCaption, color: tokens.globalTextMuted);
}
final boundName = reg.boundName(cwd);
final selectedLabel = boundName ?? _defaultLabel;
return ClideAnchoredOverlay(
controller: _overlay,
align: ClideAnchorAlign.start,
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
ClideMenuItem(
label: _defaultLabel,
active: boundName == null,
semanticLabel: 'Account for this workspace: $_defaultLabel',
onSelect: () => _bind(services, cwd, null),
),
const ClideMenuSeparator(),
for (final a in accounts)
ClideMenuItem(
label: a.name,
active: a.name == boundName,
semanticLabel: 'Account for this workspace: ${a.name}',
onSelect: () => _bind(services, cwd, a.name),
),
],
),
anchor: Semantics(
button: true,
label: 'Account for this workspace: $selectedLabel. Click to change.',
excludeSemantics: true,
onTap: _overlay.toggle,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: _overlay.toggle,
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideText(selectedLabel, color: tokens.globalForeground),
const SizedBox(width: 6),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
],
),
),
),
),
);
}
}
/// Settings control for the global Claude accounts registry (T-482, epic
/// T-476). Lists each registered account — sign-in status dot, name, config
/// dir — with re-login and remove affordances, plus an inline "add account"
/// field. Management flows through the AccountRegistry + accountActionChannel
/// (the CLI verbs' path); removal is refused while a workspace is bound, to
/// match `clide claude account remove`.
class ClaudeAccountsListControl extends StatefulWidget {
const ClaudeAccountsListControl({super.key});
@override
State<ClaudeAccountsListControl> createState() => _ClaudeAccountsListControlState();
}
class _ClaudeAccountsListControlState extends State<ClaudeAccountsListControl> {
final TextEditingController _name = TextEditingController();
final FocusNode _focus = FocusNode(debugLabel: 'add-account');
SettingsStore? _settings;
@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);
_name.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _add(KernelServices services) async {
final name = _name.text.trim();
if (name.isEmpty) return;
final reg = AccountRegistry(services.settings);
if (reg.accountByName(name) != null) {
_name.clear();
return; // idempotent — already registered
}
final dir = '${Platform.environment['HOME'] ?? ''}/.claude-$name';
_name.clear();
final write = reg.registerAccount(name, dir);
// Kick off the login flow for the new account (T-485 consumer opens it).
services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': name, 'dir': dir});
await write;
}
void _relogin(KernelServices services, Account a) {
services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': a.name, 'dir': a.dir});
}
Future<void> _remove(KernelServices services, String name) => AccountRegistry(services.settings).removeAccount(name);
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final services = ClideKernel.maybeOf(context);
if (services == null) return const SizedBox.shrink();
final reg = AccountRegistry(services.settings);
final accounts = reg.accounts;
final bound = reg.boundAccountNames();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (accounts.isEmpty)
ClideText('No accounts registered yet.', fontSize: clideFontCaption, color: tokens.globalTextMuted)
else
for (final a in accounts) _row(context, services, tokens, a, bound.contains(a.name)),
const SizedBox(height: 10),
_addRow(context, services, tokens),
],
);
}
Widget _row(BuildContext context, KernelServices services, SurfaceTokens tokens, Account a, bool isBound) {
final signedIn = accountIsSignedIn(a.dir);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(shape: BoxShape.circle, color: signedIn ? tokens.statusSuccess : tokens.globalTextMuted),
),
const SizedBox(width: 8),
ClideText(a.name, color: tokens.globalForeground),
const SizedBox(width: 10),
Expanded(
child: ClideText(a.dir, fontSize: clideFontCaption, muted: true, fontFamily: ClideSettings.fonts.monoOf(context), overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
_iconButton(context, 'sign-in', signedIn ? 'Re-sign in to ${a.name}' : 'Sign in to ${a.name}', () => _relogin(services, a)),
const SizedBox(width: 6),
_iconButton(
context,
'trash',
isBound ? '${a.name} is bound to a workspace — unset it first' : 'Remove ${a.name}',
isBound ? null : () => _remove(services, a.name),
color: isBound ? tokens.globalTextMuted : tokens.statusError,
),
],
),
);
}
Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) {
final tokens = ClideSettings.theme.of(context).surface;
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: color ?? (hovered ? tokens.globalForeground : tokens.globalTextMuted)),
),
);
}
Widget _addRow(BuildContext context, KernelServices services, SurfaceTokens tokens) {
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: _name,
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),
),
),
),
const SizedBox(width: 8),
Semantics(
button: true,
label: 'Add account',
excludeSemantics: true,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: () => _add(services),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
child: ClideText('Add account', color: tokens.buttonForeground, fontSize: clideFontCaption),
),
),
),
],
);
}
}
/// Compact account badge for the Claude pane chrome (T-481, epic T-476). Shows
/// the workspace's bound account (or "default"), tinted by [accountAccent] so
/// each window is distinguishable at a glance. Tapping opens a picker of the
/// registered accounts + Default. Hidden when no accounts are registered (the
/// feature is unused). Live via the settings notifier.
class ClaudeAccountBadge extends StatefulWidget {
const ClaudeAccountBadge({super.key, required this.workspaceRoot});
final String? workspaceRoot;
@override
State<ClaudeAccountBadge> createState() => _ClaudeAccountBadgeState();
}
class _ClaudeAccountBadgeState extends State<ClaudeAccountBadge> {
final ClideOverlayController _overlay = ClideOverlayController();
SettingsStore? _settings;
@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);
_overlay.dispose();
super.dispose();
}
Future<void> _pick(KernelServices services, String? name) async {
_overlay.close();
final cwd = widget.workspaceRoot;
if (cwd != null) await bindWorkspaceAccount(services, cwd, name);
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final services = ClideKernel.maybeOf(context);
final cwd = widget.workspaceRoot;
if (services == null || cwd == null) return const SizedBox.shrink();
final reg = AccountRegistry(services.settings);
final accounts = reg.accounts;
if (accounts.isEmpty) return const SizedBox.shrink(); // feature unused — no chrome noise
final boundName = reg.boundName(cwd);
final label = boundName ?? 'default';
final accent = accountAccent(boundName, tokens);
return ClideAnchoredOverlay(
controller: _overlay,
align: ClideAnchorAlign.end,
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
ClideMenuItem(label: 'default', active: boundName == null, semanticLabel: 'Account: default', onSelect: () => _pick(services, null)),
const ClideMenuSeparator(),
for (final a in accounts)
ClideMenuItem(label: a.name, active: a.name == boundName, semanticLabel: 'Account: ${a.name}', onSelect: () => _pick(services, a.name)),
],
),
anchor: Semantics(
button: true,
label: 'Claude account: $label. Click to change.',
excludeSemantics: true,
onTap: _overlay.toggle,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: _overlay.toggle,
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(shape: BoxShape.circle, color: accent),
),
const SizedBox(width: 5),
ClideText(label, fontSize: clideFontCaption, color: boundName == null ? tokens.globalTextMuted : tokens.globalForeground),
],
),
),
),
),
);
}
}
@@ -162,6 +162,8 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
case UserMessage():
case AssistantTextMessage():
case ImageMessage():
case DrawingMessage():
case IconMessage():
return false;
// Thinking folds at L2+, first-class at L1.
case AssistantThinkingMessage():
+32 -2
View File
@@ -58,6 +58,16 @@ String clideContextNote(String workspaceRoot) =>
'is how you observe and drive the same workspace the user sees — prefer it for IDE actions so '
'your work and the user\'s stay in one shared workspace.';
/// Nudge a FRESH session to reach for the bundled skills from its first turn
/// rather than rediscovering the workflows (T-490). Layered on top of
/// [clideContextNote]; only injected for new sessions (not --resume / forks),
/// so a session that already carries context is never re-nagged.
String clideSkillsNote() =>
'Two skills are available in this workspace — load and use them from your first turn instead '
'of rediscovering their workflows: `pql` (planning, decisions, tickets, and vault queries) and '
'`clide` (driving this IDE). Reach for the matching skill whenever a task touches planning or '
'tickets, or the clide surface.';
/// Build the environment DELTA to overlay on a hosted session's inherited
/// environment (T-215). `Process.start` keeps the parent environment by
/// default, so this returns only the keys to add/override:
@@ -74,6 +84,21 @@ Map<String, String> agentEnvDelta({required String workspaceRoot, required Strin
return delta;
}
/// Resolve the `CLAUDE_CONFIG_DIR` a session in [cwd] should run under (T-484,
/// epic T-476): the bound account's dir when the workspace is bound, else the
/// parent's `CLAUDE_CONFIG_DIR` when the launcher already set one, else null
/// (Claude defaults to `~/.claude`).
///
/// Pure: the AccountRegistry is injected as a plain [boundConfigDir] lookup
/// (workspace → bound config dir, or null) so this stays Flutter-free — the
/// registry itself lives behind a ChangeNotifier the orchestrator owns.
String? claudeConfigDirForWorkspace({required String cwd, required String? Function(String cwd) boundConfigDir, required Map<String, String> env}) {
final bound = boundConfigDir(cwd);
if (bound != null && bound.isNotEmpty) return bound;
final inherited = env['CLAUDE_CONFIG_DIR'];
return (inherited != null && inherited.isNotEmpty) ? inherited : null;
}
/// Locate the directory to prepend to a hosted agent's PATH so `clide`
/// resolves (T-215). Returns null when `clide` is ALREADY on [currentPath]
/// (the installed case — T-211 drops it in `~/.local/bin`, normally already
@@ -107,7 +132,7 @@ 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}) {
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base, String? Function(String cwd)? boundConfigDir}) {
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
@@ -120,7 +145,12 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
// 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
// env > unset); omitted entirely when there's nothing to set.
final configDir = claudeConfigDirForWorkspace(cwd: workspaceRoot, boundConfigDir: boundConfigDir ?? (_) => null, env: Platform.environment);
return AgentBootstrap(envDelta: {'CLAUDE_CONFIG_DIR': ?configDir, ...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
}
bool _isExecutableFile(String path) {
@@ -19,9 +19,10 @@
/// `meta_sidebar/` (T-395 split). Activity and Config render on the same
/// table geometry (`buildMetaTable`) so switching tabs doesn't visually jump.
///
/// The account/team token budget is intentionally absent: it isn't
/// programmatically exposed under subscription auth (see project memory /
/// GitHub anthropics/claude-code#44328).
/// The account budget surfaces from a forwarded `/usage` (T-415): the Activity
/// tab renders it next to its refresh control. It is NOT duplicated on the Team
/// tab — usage is per-account (one `~/.claude` login), so it can't be split per
/// member; one place to see it is enough (T-158).
library;
import 'dart:async';
+29 -3
View File
@@ -5,6 +5,7 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'account_settings_control.dart';
import 'claude_banner.dart';
import 'claude_composer.dart';
import 'claude_config.dart';
@@ -728,7 +729,15 @@ class _ClaudePaneState extends State<ClaudePane> {
@override
Widget build(BuildContext context) {
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
final title = widget.isPrimary
? ClideSettings.i18n.string(context, 'pane.title.primary', namespace: 'builtin.claude', placeholder: 'claude — primary')
: ClideSettings.i18n.interpolated(
context,
'pane.title.secondary',
namespace: 'builtin.claude',
placeholder: 'claude — secondary ${widget.secondaryIndex}',
replacers: [I18nReplacer(from: '{index}', replace: '${widget.secondaryIndex}')],
);
final tokens = ClideSettings.theme.of(context).surface;
final Widget body;
@@ -761,7 +770,15 @@ class _ClaudePaneState extends State<ClaudePane> {
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
role: widget.isPrimary
? ClideSettings.i18n.string(context, 'banner.role.primary', namespace: 'builtin.claude', placeholder: 'primary')
: ClideSettings.i18n.interpolated(
context,
'banner.role.secondary',
namespace: 'builtin.claude',
placeholder: 'session ${widget.secondaryIndex}',
replacers: [I18nReplacer(from: '{index}', replace: '${widget.secondaryIndex}')],
),
workspace: _repoRoot,
statusLine: _statusLine,
),
@@ -838,7 +855,16 @@ class _ClaudePaneState extends State<ClaudePane> {
);
}
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
final content = widget.showChrome
? ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
// Per-repo Claude account badge (T-481): shows + switches the
// account this workspace is bound to; hidden when none registered.
trailing: [ClaudeAccountBadge(workspaceRoot: _repoRoot)],
child: body,
)
: body;
// Surface this pane's status to the bottom status-bar slot while it's
// the focused pane (T-150).
+209 -17
View File
@@ -12,6 +12,8 @@ library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data' show Uint8List;
import 'dart:ui' as ui;
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
@@ -19,9 +21,14 @@ import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabe
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
import 'package:clide/builtin/claude/src/icon_card.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/src/svg/svg_document.dart' show buildSvgDocument;
import 'package:clide/src/svg/svg_node.dart' show SvgDocument;
import 'package:clide/widgets/src/draw/drawing_card.dart';
import 'package:clide/widgets/src/svg/svg_painter.dart' show SvgView;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/keymap/intents.dart';
@@ -701,9 +708,41 @@ class _ConversationTurn extends StatelessWidget {
AssistantToolUse() => collapseTools ? _toolUseCollapser(context, i) : _toolContentCard(context, i),
ToolResultMessage() => _toolResult(context, i),
ImageMessage() => _image(context, i),
DrawingMessage() => _drawing(context, i),
IconMessage() => _icon(context, i),
};
}
/// A driven-in drawing card (T-318): the SVG rendered inline by clide's own
/// CustomPaint engine (D-103), display-only per D-78, with an optional
/// label/description caption.
Widget _drawing(BuildContext context, DrawingMessage m) {
return ConversationCard(
accent: tokens.globalTextMuted,
label: ClideSettings.i18n.string(context, 'conversation.label.drawing', namespace: 'builtin.claude', placeholder: 'drawing'),
body: _DrawingWithImages(
doc: buildSvgDocument(m.svg),
label: m.label,
description: m.description,
source: m.source,
sourceLabel: m.source == null
? null
: ClideSettings.i18n.string(context, 'conversation.draw.viewSource', namespace: 'builtin.claude', placeholder: 'view d2 source'),
),
);
}
/// A driven-in Phosphor glyph card (T-313): each glyph at a hero size plus a
/// real-UI-size strip, with optional label/description/color. Display-only
/// per D-78 — selection happens in the interaction zone, not on the card.
Widget _icon(BuildContext context, IconMessage m) {
return ConversationCard(
accent: tokens.globalTextMuted,
label: ClideSettings.i18n.string(context, 'conversation.label.icon', namespace: 'builtin.claude', placeholder: 'icons'),
body: IconGlyphCard(entries: m.entries, defaultColor: m.color),
);
}
/// A driven-in image card (T-249): the image rendered inline, clide-owned
/// (Flutter's [Image.file], no third-party viewer), display-only per D-78.
/// Bounded so a large image scales down to the pane width and never pushes
@@ -718,6 +757,11 @@ class _ConversationTurn extends StatelessWidget {
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Annotation title above the image (T-316), when a --file payload set it.
if (m.label != null && m.label!.isNotEmpty) ...[
ClideText(m.label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground),
const SizedBox(height: 4),
],
// The card stays display-only (D-78); the click is a navigation
// gesture that opens the full-screen lightbox (T-252), not an inline
// control.
@@ -739,6 +783,10 @@ class _ConversationTurn extends StatelessWidget {
),
),
),
if (m.description != null && m.description!.isNotEmpty) ...[
const SizedBox(height: 6),
ClideText(m.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
],
if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
],
),
@@ -800,10 +848,10 @@ class _ConversationTurn extends StatelessWidget {
final outcome = toolUseOutcomes[t.toolUseId];
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
final collapser = ClideCollapserCard(
label: t.name,
label: _toolNameLabel(context, t.name),
color: color,
collapsedSummary: _toolUseSummary(t),
counter: '1 step',
counter: _stepsCounter(context, 1),
status: _toolRunStatus(t),
children: [_toolContentCard(context, t)],
);
@@ -818,8 +866,8 @@ class _ConversationTurn extends StatelessWidget {
padding: const EdgeInsets.only(left: 12),
child: ClideCollapserCard(
label: ClideSettings.i18n.string(context, 'conversation.label.agentRun', namespace: 'builtin.claude', placeholder: 'agent run'),
collapsedSummary: _summarizeActivity(runItems.last),
counter: runItems.length == 1 ? '1 step' : '${runItems.length} steps',
collapsedSummary: _summarizeActivity(context, runItems.last),
counter: _stepsCounter(context, runItems.length),
children: [
for (final r in runItems)
_ConversationTurn(
@@ -849,7 +897,18 @@ class _ConversationTurn extends StatelessWidget {
Widget _workflowCard(BuildContext context, AssistantToolUse t, WorkflowRun run) {
final title = run.name ?? ClideSettings.i18n.string(context, 'conversation.label.workflow', namespace: 'builtin.claude', placeholder: 'workflow');
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
final counter = run.agentCount == 0
? ClideSettings.i18n.string(context, 'conversation.counter.starting', namespace: 'builtin.claude', placeholder: 'starting')
: ClideSettings.i18n.interpolated(
context,
'conversation.counter.agents',
namespace: 'builtin.claude',
placeholder: '${run.doneCount}/${run.agentCount} agents',
replacers: [
I18nReplacer(from: '{done}', replace: '${run.doneCount}'),
I18nReplacer(from: '{total}', replace: '${run.agentCount}'),
],
);
final detail = run.done ? (run.summary ?? run.description) : run.description;
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
return ClideCollapserCard(
@@ -990,7 +1049,7 @@ class _ConversationTurn extends StatelessWidget {
variant: ConversationCardVariant.bordered,
accent: accent,
borderColor: outcome == null ? null : accent,
label: t.name,
label: _toolNameLabel(context, t.name),
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
status: status,
body: toolInputBody(context, tokens, t.name, t.input, mono),
@@ -1047,7 +1106,7 @@ class _ConversationTurn extends StatelessWidget {
variant: ConversationCardVariant.bordered,
accent: quiet ? tokens.globalTextMuted : accent,
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
label: paired != null ? '${_toolNameLabel(context, paired.name)} · $errLabel' : errLabel,
copyText: t.content,
collapsible: quiet || multiline,
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
@@ -1061,7 +1120,7 @@ class _ConversationTurn extends StatelessWidget {
// For Write/Edit, the result is usually "OK" — keep it as plain text.
final multiline = t.content.contains('\n');
final isOutputTool = paired != null && const {'Bash', 'Read', 'Grep', 'LS'}.contains(paired.name);
final resultLabel = paired != null ? '${paired.name} · $label' : label;
final resultLabel = paired != null ? '${_toolNameLabel(context, paired.name)} · $label' : label;
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: accent,
@@ -1127,8 +1186,8 @@ class _ActivityCard extends StatelessWidget {
final count = items.length;
return ClideCollapserCard(
label: ClideSettings.i18n.string(context, 'conversation.cluster.activity', namespace: 'builtin.claude', placeholder: 'Activity'),
collapsedSummary: _summarizeActivity(items.last),
counter: count == 1 ? '1 step' : '$count steps',
collapsedSummary: _summarizeActivity(context, items.last),
counter: _stepsCounter(context, count),
status: _runStatus(items, resultByToolUseId),
children: [
for (final item in items)
@@ -1150,6 +1209,36 @@ class _ActivityCard extends StatelessWidget {
}
}
/// Localized display label for a tool name (T-462). File/web/task operations
/// have natural translations; command/proper-name tools (Bash, Grep, Glob,
/// ScheduleWakeup, MCP tools, …) have no catalog key by design and fall back to
/// the raw name — so `warnIfMissing: false` keeps a miss from logging (T-493).
String _toolNameLabel(BuildContext context, String name) =>
ClideSettings.i18n.string(context, 'tool.name.$name', namespace: 'builtin.claude', placeholder: name, warnIfMissing: false);
/// Localized "N steps" counter for a collapser header (T-462). Singular and
/// plural are distinct catalog keys; the English forms double as the fallback.
String _stepsCounter(BuildContext context, int n) => n == 1
? ClideSettings.i18n.string(context, 'conversation.counter.step', namespace: 'builtin.claude', placeholder: '1 step')
: ClideSettings.i18n.interpolated(
context,
'conversation.counter.steps',
namespace: 'builtin.claude',
placeholder: '$n steps',
replacers: [I18nReplacer(from: '{count}', replace: '$n')],
);
/// Localized "N edits" counter for the edit-run collapser header (T-462).
String _editsCounter(BuildContext context, int n) => n == 1
? ClideSettings.i18n.string(context, 'conversation.counter.edit', namespace: 'builtin.claude', placeholder: '1 edit')
: ClideSettings.i18n.interpolated(
context,
'conversation.counter.edits',
namespace: 'builtin.claude',
placeholder: '$n edits',
replacers: [I18nReplacer(from: '{count}', replace: '$n')],
);
/// Aggregate live status for a run's header tick (T-296): error if any tool in
/// the run failed, else running while its last tool awaits a result, else
/// success. Null (no tools) shows no indicator.
@@ -1193,8 +1282,8 @@ class _EditRunCard extends StatelessWidget {
final count = edits.length;
return ClideCollapserCard(
label: ClideSettings.i18n.string(context, 'conversation.cluster.edits', namespace: 'builtin.claude', placeholder: 'Edits'),
collapsedSummary: _summarizeActivity(edits.last),
counter: count == 1 ? '1 edit' : '$count edits',
collapsedSummary: _summarizeActivity(context, edits.last),
counter: _editsCounter(context, count),
status: _runStatus(edits, resultByToolUseId),
children: [
for (final item in edits)
@@ -1216,22 +1305,125 @@ class _EditRunCard extends StatelessWidget {
}
/// One-line summary of a folded item for the collapsed ticker.
String _summarizeActivity(ConversationItem item) {
String _summarizeActivity(BuildContext context, ConversationItem item) {
String label(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.claude', placeholder: fallback);
switch (item) {
case AssistantToolUse(:final name, :final input):
final raw = input['command'] ?? input['file_path'] ?? input['path'] ?? input['pattern'] ?? input['url'];
final detail = raw is String ? raw.split('\n').first.trim() : '';
final clipped = detail.length > 72 ? '${detail.substring(0, 72)}' : detail;
return clipped.isEmpty ? name : '$name $clipped';
final toolName = _toolNameLabel(context, name);
return clipped.isEmpty ? toolName : '$toolName $clipped';
case ToolResultMessage(:final isError):
return isError ? '↳ result · error' : '↳ result';
final result = label('conversation.label.result', 'result');
return isError ? '$result · ${label('conversation.label.error', 'error')}' : '$result';
case AssistantThinkingMessage():
return 'thinking';
return '${label('conversation.label.thinking', 'thinking')}';
case UserMessage(:final text):
return text;
case AssistantTextMessage(:final text):
return text;
case ImageMessage(:final path):
return 'image $path';
return '${label('conversation.label.image', 'image')} $path';
case DrawingMessage(label: final cardLabel):
return '${label('conversation.label.drawing', 'drawing')}${cardLabel != null ? ' $cardLabel' : ''}';
case IconMessage(:final entries):
return '${label('conversation.label.icon', 'icons')} ${entries.map((e) => e.name).join(', ')}';
}
}
/// Decode every annotated `<image>` href in [doc] into a `ui.Image` (T-319) —
/// the resolver the renderer + lightbox paint through. A missing/undecodable
/// file is skipped (its cell paints empty), never thrown. [load] (read bytes)
/// and [decode] are injectable so the path is testable off the real filesystem.
Future<Map<String, ui.Image>> loadDrawingImages(
SvgDocument doc, {
Future<Uint8List?> Function(String path)? load,
Future<ui.Image> Function(Uint8List bytes)? decode,
}) async {
final reader = load ?? _readFileBytes;
final decoder = decode ?? decodeImageFromList;
final out = <String, ui.Image>{};
for (final href in doc.annotations.map((a) => a.href).whereType<String>().toSet()) {
try {
final bytes = await reader(href);
if (bytes != null) out[href] = await decoder(bytes);
} catch (_) {
// Missing/undecodable — leave that cell empty.
}
}
return out;
}
Future<Uint8List?> _readFileBytes(String path) async {
try {
return await File(path).readAsBytes();
} catch (_) {
return null;
}
}
/// Wraps a [DrawingCard], loading any `<image>` hrefs the SVG references into
/// `ui.Image`s first (T-319) — the renderer + lightbox both paint through the
/// resolver. A drawing with no images (d2, raw svg) loads nothing and renders
/// immediately; a missing/unreadable file is skipped (that cell paints empty).
class _DrawingWithImages extends StatefulWidget {
const _DrawingWithImages({required this.doc, this.label, this.description, this.source, this.sourceLabel});
final SvgDocument doc;
final String? label, description, source, sourceLabel;
@override
State<_DrawingWithImages> createState() => _DrawingWithImagesState();
}
class _DrawingWithImagesState extends State<_DrawingWithImages> {
final Map<String, ui.Image> _images = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final imgs = await loadDrawingImages(widget.doc);
if (imgs.isEmpty) return;
if (!mounted) {
for (final img in imgs.values) {
img.dispose();
}
return;
}
setState(() => _images.addAll(imgs));
}
@override
void dispose() {
for (final img in _images.values) {
img.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final resolver = _images.isEmpty ? null : (String href) => _images[href];
return DrawingCard(
document: widget.doc,
images: resolver,
label: widget.label,
description: widget.description,
source: widget.source,
sourceLabel: widget.sourceLabel,
// A data-lightbox element opens the whole drawing, zoomable (T-318); the
// lightbox paints through the same image resolver (T-319).
onLightbox: () => ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: SvgView(document: widget.doc, images: resolver),
),
),
);
}
}
+167 -3
View File
@@ -2,6 +2,10 @@ import 'dart:async';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/account_registry.dart';
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';
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
@@ -15,7 +19,11 @@ import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes;
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show DrawingMessage, IconEntry, IconMessage, ImageMessage;
import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel;
import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel;
import 'package:clide/src/daemon/draw_commands.dart' show drawShowChannel;
import 'package:clide/src/daemon/icon_commands.dart' show iconShowChannel;
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
import 'package:clide/builtin/claude/src/team_panel_host.dart';
@@ -44,6 +52,9 @@ class ClaudeExtension extends ClideExtension {
@override
String get version => '0.2.0';
@override
// No runtime dependency on builtin.terminal: the login pane (T-485) reuses the
// TerminalPane *widget* (a code import), which spawns via the always-present
// pane.spawn IPC — it doesn't need the terminal extension activated.
List<String> get dependsOn => const [];
ClideExtensionContext? _ctx;
@@ -196,9 +207,41 @@ class ClaudeExtension extends ClideExtension {
),
],
),
// Per-repo Claude account (T-482, epic T-476). The dropdown binds this
// workspace to a registered account; manage the registry itself with
// the `clide claude account` verbs (T-480).
SettingsSection(
label: 'Account',
labelKey: 'settings.claude.account.label',
fields: [
SettingsField(
// Placeholder keys — custom fields are rendered by their control,
// never stored here; kept clear of the app.claude.account.<hash>
// binding namespace the registry scans (T-480).
key: 'app.claude.accountsRegistry',
kind: SettingsFieldKind.custom,
label: 'Accounts',
labelKey: 'settings.claude.account.registry.label',
help: 'Registered Claude accounts (each a separate config dir + login).',
helpKey: 'settings.claude.account.registry.help',
customId: 'claude.accounts',
),
SettingsField(
key: 'app.claude.workspaceAccount',
kind: SettingsFieldKind.custom,
label: 'Account for this workspace',
labelKey: 'settings.claude.account.workspace.label',
help: 'Which Claude account this repo runs under; Default uses the system login.',
helpKey: 'settings.claude.account.workspace.help',
customId: 'claude.workspace-account',
),
],
),
],
),
),
SettingsControlContribution(id: 'claude.accounts', customId: 'claude.accounts', builder: (_) => const ClaudeAccountsListControl()),
SettingsControlContribution(id: 'claude.workspace-account', customId: 'claude.workspace-account', builder: (_) => const ClaudeWorkspaceAccountControl()),
// T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show <sessionId>
CommandContribution(
@@ -467,8 +510,9 @@ 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.
_orchestrator = ClaudeSessionOrchestrator();
// 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));
activeSessionOrchestrator = _orchestrator;
// An in-place workspace switch (Open Project/Folder) must not leave the
@@ -482,9 +526,73 @@ class ClaudeExtension extends ClideExtension {
// user is looking at (the primary lead, else the first visible session).
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
// `clide draw --file <doc>` (T-318): the dispatcher lowers the doc to SVG
// and publishes a 'draw' message; we inject the drawing card into the
// conversation the user is looking at.
_subs.add(ctx.messages.subscribe(channel: drawShowChannel).listen(_onDrawShow));
// `clide icon show <name…>` (T-313): the dispatcher resolves the glyphs and
// publishes an 'icon' message; we inject the glyph card.
_subs.add(ctx.messages.subscribe(channel: iconShowChannel).listen(_onIconShow));
// A sidebar "pick up" click (T-327) publishes the full ticket; inject it
// into the active conversation as a user turn so Claude starts working it.
_subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp));
// `clide claude account set/unset/remove --purge` (T-480): the dispatcher
// writes the registry then publishes here; only the UI layer can respawn
// the workspace's panes onto the newly-bound account or delete a config dir.
_subs.add(ctx.messages.subscribe(channel: accountActionChannel).listen(_onAccountAction));
// A freshly-created project (T-488) announces itself once it's open; show the
// per-repo account roadblock so the user binds it now (existing opens, which
// never announce, are never prompted).
_subs.add(ctx.messages.subscribe(channel: projectCreatedChannel).listen(_onProjectCreated));
}
void _onProjectCreated(Message m) {
final dir = m.data['dir'] as String?;
final ctx = _ctx;
if (dir == null || ctx == null) return;
final name = dir.split('/').where((s) => s.isNotEmpty).lastOrNull ?? dir;
ctx.dialog.show<Object>((c, dismiss) => ClaudeAccountRoadblockDialog(projectName: name, onClose: dismiss));
}
/// Side-effects for the `claude account` verbs (T-480). The dispatcher does
/// the registry write and publishes the action here; respawning panes,
/// deleting a config dir, and (future) the login terminal pane are UI-layer
/// concerns the Flutter-free handler can't do itself.
void _onAccountAction(Message m) {
switch (m.data['action'] as String?) {
case 'set':
case 'unset':
final cwd = m.data['cwd'] as String?;
final orch = _orchestrator;
if (cwd != null && orch != null) unawaited(orch.respawnForWorkspace(cwd));
case 'purge':
final dir = m.data['dir'] as String?;
if (dir != null) unawaited(_purgeAccountDir(dir));
case 'login':
final name = m.data['name'] as String?;
final dir = m.data['dir'] as String?;
final ctx = _ctx;
// Host `CLAUDE_CONFIG_DIR=<dir> claude login` in a modal terminal pane
// (T-485); the CLI owns the OAuth browser flow.
if (name != null && dir != null && ctx != null) {
ctx.dialog.show<Object>((c, dismiss) => ClaudeLoginDialog(name: name, dir: dir, cwd: _projectRoot, onClose: dismiss));
}
}
}
/// Delete a purged account's config dir (`remove --purge`). Guarded: only a
/// `~/.claude-*` directory that is a direct child of the user's home is ever
/// removed — never an arbitrary path, even though the dir came from our own
/// registry. A `rm -rf` of the wrong dir is unrecoverable.
Future<void> _purgeAccountDir(String dir) async {
final home = Platform.environment['HOME'];
if (home == null || !isPurgeableAccountDir(dir, home)) return;
final d = Directory(dir);
if (await d.exists()) await d.delete(recursive: true);
}
/// Hand a picked-up ticket to the active Claude session (T-327/T-339). The
@@ -538,6 +646,62 @@ class ClaudeExtension extends ClideExtension {
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
label: m.data['label'] as String?,
description: m.data['description'] as String?,
),
);
}
/// Inject an [IconMessage] from a published `icon` bus message (T-313).
void _onIconShow(Message m) {
final raw = m.data['entries'];
if (raw is! List || raw.isEmpty) return;
final entries = <IconEntry>[];
for (final item in raw) {
if (item is! Map) continue;
final cp = item['codepoint'];
if (cp is! int) continue;
entries.add(
IconEntry(
codepoint: cp,
name: item['name'] as String? ?? '',
label: item['label'] as String?,
description: item['description'] as String?,
color: item['color'] as String?,
),
);
}
if (entries.isEmpty) return;
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return;
target.conversation.inject(
IconMessage(
uuid: 'icon-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
entries: entries,
color: m.data['color'] as String?,
),
);
}
/// Inject a [DrawingMessage] from a published `draw` bus message (T-318).
/// Dropped silently if no live conversation is available — the CLI already
/// reported success at publish time, and a missing pane is transient.
void _onDrawShow(Message m) {
final svg = m.data['svg'] as String?;
if (svg == null || svg.isEmpty) return;
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return;
target.conversation.inject(
DrawingMessage(
uuid: 'draw-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
svg: svg,
label: m.data['label'] as String?,
description: m.data['description'] as String?,
source: m.data['source'] as String?,
),
);
}
+89
View File
@@ -0,0 +1,89 @@
/// The Phosphor glyph card (T-313) — display-only per D-78.
///
/// Each entry shows a HERO glyph (legible detail) plus a continuous sample strip
/// at the real UI sizes (1048), so a reviewer judges how the glyph reads where
/// the app actually uses it; the optional per-entry label + description turn the
/// card into a labelled offer the interaction zone can mirror as a choice list.
/// A per-entry or card-level `color` (hex or CSS name) tints the glyph — content
/// color, not a clide token (the glyph is for whatever project we're on); it
/// falls back to the card foreground.
library;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show IconEntry;
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/src/svg/svg_color.dart' show parseSvgColor;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class IconGlyphCard extends StatelessWidget {
const IconGlyphCard({super.key, required this.entries, this.defaultColor});
final List<IconEntry> entries;
/// Card-level default glyph color (hex / CSS name), applied to entries without
/// their own.
final String? defaultColor;
/// One continuous sample strip, smallest → largest (T-313, finalized set).
static const _sizes = <double>[10, 11, 12, 13, 14, 15, 18, 20, 24, 32, 48];
static const _hero = 52.0;
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final cardColor = _parse(defaultColor) ?? tokens.globalForeground;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < entries.length; i++) ...[if (i > 0) const SizedBox(height: 18), _entry(tokens, entries[i], cardColor)],
],
);
}
Widget _entry(SurfaceTokens tokens, IconEntry e, Color cardColor) {
final color = _parse(e.color) ?? cardColor;
final painter = PhosphorIconPainter(e.codepoint);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (e.label != null && e.label!.isNotEmpty) ClideText(e.label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground),
if (e.description != null && e.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: ClideText(e.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClideIcon(painter, size: _hero, color: color),
const SizedBox(width: 20),
Expanded(
child: Wrap(
spacing: 14,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.end,
children: [for (final s in _sizes) _sample(tokens, painter, color, s)],
),
),
],
),
],
);
}
Widget _sample(SurfaceTokens tokens, PhosphorIconPainter painter, Color color, double size) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(painter, size: size, color: color),
const SizedBox(height: 2),
ClideText('${size.toInt()}', fontSize: clideFontBadge, color: tokens.globalTextMuted),
],
);
Color? _parse(String? raw) {
if (raw == null) return null;
final argb = parseSvgColor(raw);
return argb == null ? null : Color(argb);
}
}
@@ -87,59 +87,61 @@ class ActivityTabView extends StatelessWidget {
children: [
// SESSION control strip (T-415): drives the primary session through
// the builtin.claude/command bus — identical to typing the command.
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClideText(
ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION'),
fontSize: clideFontSmall,
color: tokens.sidebarSectionHeader,
// Carded to match the settings overlay (T-158 facelift).
metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION')),
metaCard(tokens, [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Row(
children: [
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.clear', namespace: 'builtin.claude', placeholder: 'clear'),
'trash',
'/clear',
),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.compact', namespace: 'builtin.claude', placeholder: 'compact'),
'arrows-in-simple',
'/compact',
),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.fork', namespace: 'builtin.claude', placeholder: 'fork'),
'git-branch',
'/fork',
),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.resume', namespace: 'builtin.claude', placeholder: 'resume'),
'clock-counter-clockwise',
'/resume',
),
const Spacer(),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.refreshUsage', namespace: 'builtin.claude', placeholder: 'refresh usage'),
'arrow-clockwise',
'/usage',
),
],
),
),
),
Row(
children: [
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.clear', namespace: 'builtin.claude', placeholder: 'clear'),
'trash',
'/clear',
),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.compact', namespace: 'builtin.claude', placeholder: 'compact'),
'arrows-in-simple',
'/compact',
),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.fork', namespace: 'builtin.claude', placeholder: 'fork'),
'git-branch',
'/fork',
),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.resume', namespace: 'builtin.claude', placeholder: 'resume'),
'clock-counter-clockwise',
'/resume',
),
const Spacer(),
_control(
context,
tokens,
ClideSettings.i18n.string(context, 'activity.control.refreshUsage', namespace: 'builtin.claude', placeholder: 'refresh usage'),
'arrow-clockwise',
'/usage',
),
],
),
const SizedBox(height: 6),
]),
if (sections.isNotEmpty) const SizedBox(height: 16),
if (sections.isEmpty)
metaPlaceholder(ClideSettings.i18n.string(context, 'activity.empty', namespace: 'builtin.claude', placeholder: 'No activity recorded yet.'))
Padding(
padding: const EdgeInsets.only(top: 12),
child: metaPlaceholder(ClideSettings.i18n.string(context, 'activity.empty', namespace: 'builtin.claude', placeholder: 'No activity recorded yet.')),
)
else
...metaTableChildren(tokens, sections),
...metaTableChildren(context, tokens, sections),
],
);
}
@@ -53,43 +53,40 @@ class ConfigTabView extends StatelessWidget {
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
final children = <Widget>[
// Pinned SETTINGS control panel — not collapsible.
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClideText(
ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS'),
fontSize: clideFontSmall,
color: tokens.sidebarSectionHeader,
// Pinned SETTINGS control panel — not collapsible. Carded to match the
// settings overlay (T-158 facelift).
metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS')),
metaCard(tokens, [
SettingControlRow(
label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'),
value: model,
valueColor: tokens.globalFocus,
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
command: 'model',
),
),
SettingControlRow(
label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'),
value: model,
valueColor: tokens.globalFocus,
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
command: 'model',
),
SettingControlRow(
label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'),
value: effort,
options: kEffortLevels,
isActive: (o) => o.value == effort,
command: 'effort',
),
SettingControlRow(
label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'),
value: permissionModeLabel(mode),
options: kPermissionModes,
isActive: (o) => o.value == mode,
command: 'permissions',
),
_configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle),
_configRow(
tokens,
ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'),
ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'),
),
SettingControlRow(
label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'),
value: effort,
options: kEffortLevels,
isActive: (o) => o.value == effort,
command: 'effort',
),
SettingControlRow(
label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'),
value: permissionModeLabel(mode),
options: kPermissionModes,
isActive: (o) => o.value == mode,
command: 'permissions',
),
_configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle),
_configRow(
tokens,
ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'),
ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'),
),
]),
const SizedBox(height: 12),
// ---- Accordion sections ----
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
@@ -116,7 +113,7 @@ class ConfigTabView extends StatelessWidget {
/// One read-only key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -324,7 +321,7 @@ class _SettingControlRowState extends State<SettingControlRow> {
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
+50 -27
View File
@@ -43,41 +43,64 @@ Widget metaPlaceholder(String text) => Padding(
child: ClideText(text, muted: true, fontSize: kMetaFont),
);
/// Key→value sections on the shared table geometry (Activity + Config).
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
/// Small-caps section header (mono, uppercase) — the same treatment the settings
/// overlay uses (settings_category_view `_SectionCard`), so the Claude meta
/// sidebar and the settings modal read as one card system (T-158 facelift).
Widget metaSectionHeader(BuildContext context, SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6),
child: ClideText(label.toUpperCase(), fontSize: clideFontCaption, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
);
/// The table rows without the enclosing ListView, for tabs that compose extra
/// widgets around the sections (the Activity tab's control strip, T-415).
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
/// An elevated card (the settings card surface) wrapping divider-separated
/// [rows]: `panelHeader` fill, `dividerColor` hairline border, 6px radius.
Widget metaCard(SurfaceTokens tokens, List<Widget> rows) => ClideSurface(
color: tokens.panelHeader,
border: tokens.dividerColor,
borderRadius: BorderRadius.circular(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < rows.length; i++) ...[if (i > 0) const ClideDivider(), rows[i]],
],
),
);
/// One label→value row sized for the card interior — the shared label column
/// then the value, uniform with the settings field rows.
Widget metaCardRow(SurfaceTokens tokens, MetaRow r) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: kMetaLabelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
),
Expanded(
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
),
],
),
);
/// Key→value sections rendered as carded blocks (Activity + Config).
Widget buildMetaTable(BuildContext context, SurfaceTokens tokens, List<MetaSection> sections) =>
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(context, tokens, sections));
/// The carded sections without the enclosing ListView, for tabs that compose
/// extra widgets around them (the Activity tab's control strip, T-415): a
/// small-caps header above an elevated card of label→value rows.
List<Widget> metaTableChildren(BuildContext context, SurfaceTokens tokens, List<MetaSection> sections) {
final children = <Widget>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
children.add(
Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
padding: EdgeInsets.only(top: i == 0 ? 0 : 16),
child: metaSectionHeader(context, tokens, s.header),
),
);
for (final r in s.rows) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: kMetaLabelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
),
Expanded(
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
),
],
),
),
);
}
children.add(metaCard(tokens, [for (final r in s.rows) metaCardRow(tokens, r)]));
}
return children;
}
@@ -3,6 +3,11 @@
/// Stateless and props-driven; the parent owns the member list, inject
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
/// (T-395).
///
/// The account `/usage` budget is deliberately NOT shown here: it is
/// per-account (every team session shares one `~/.claude` login), so it can't
/// be split per member — it lives once on the Activity tab, next to the
/// refresh control that fetches it (T-158).
library;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
@@ -89,12 +94,7 @@ class TeamTabView extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS'),
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
const SizedBox(height: 4),
metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS')),
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
],
);
@@ -90,17 +90,22 @@ class _RunningIndicatorState extends State<RunningIndicator> with SingleTickerPr
label: ClideSettings.i18n.string(context, 'running.semantics', namespace: 'builtin.claude', placeholder: 'Claude is running'),
child: ExcludeSemantics(
child: reduced
? ClideText('${_verbs.first}', color: claudeAccent, fontSize: clideFontMeta)
? ClideText('${_verb(context, _verbs.first)}', color: claudeAccent, fontSize: clideFontMeta)
: AnimatedBuilder(
animation: _c,
builder: (ctx, _) {
final elapsed = _c.value * _periodSeconds;
final dots = '.' * (elapsed.floor() % 4);
final word = _verbs[(elapsed ~/ _secondsPerWord) % _verbs.length];
return ClideText('$word$dots', color: claudeAccent, fontSize: clideFontMeta);
return ClideText('${_verb(ctx, word)}$dots', color: claudeAccent, fontSize: clideFontMeta);
},
),
),
);
}
/// Localize a verb through the catalog; the English word doubles as the key
/// suffix and the fallback, so a missing translation degrades to English.
String _verb(BuildContext context, String word) =>
ClideSettings.i18n.string(context, 'running.verb.${word.toLowerCase()}', namespace: 'builtin.claude', placeholder: word);
}
@@ -15,6 +15,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/account_registry.dart';
import 'package:clide/builtin/claude/src/agent_bootstrap.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
@@ -152,10 +153,15 @@ class ManagedSession {
ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry}) : _factory = processFactory ?? _spawnClaude {
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
}
/// Per-repo Claude account bindings (epic T-476). When a workspace is bound,
/// its hosted sessions spawn under that account's CLAUDE_CONFIG_DIR (T-484).
/// Null in tests / when no registry is wired → no injection.
final AccountRegistry? accountRegistry;
final ProcessFactory _factory;
final _sessions = <String, ManagedSession>{};
@@ -238,13 +244,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
// note and the team preamble merge into ONE --append-system-prompt (claude
// honours a single one).
final preambles = <String>[clideContextNote(spec.cwd)];
// Nudge a FRESH session to reach for the bundled skills (T-490). A new tab
// and the post-/clear respawn spawn with resume:false; the account-change
// respawn (T-480) and real resumes carry prior context (resume:true), and a
// fork inherits its source — none of those are re-nagged.
if (!spec.resume && !spec.isFork) preambles.add(clideSkillsNote());
if (spec.team) {
final name = spec.memberName ?? spec.role;
broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role));
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
preambles.add(_teamSystemPrompt(name, spec.role));
}
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
final bootstrap = agentBootstrap(spec.cwd, base: spec.env, boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir);
sessionArgs = [
'--append-system-prompt',
preambles.join('\n\n'),
@@ -312,6 +323,31 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
notifyListeners();
}
/// Respawn the workspace's solo sessions in place so they pick up a changed
/// per-repo Claude account (T-480). Each is closed (awaits real process
/// death, T-437) then re-spawned on the SAME id with `--resume` of its real
/// session id, so the conversation continues under the newly-bound
/// `CLAUDE_CONFIG_DIR` (resolved at spawn time by [agentBootstrap] from the
/// [accountRegistry]). Team / forked sessions are skipped — re-joining the
/// broker or re-forking on an account swap is out of scope; they adopt the
/// new account on their next natural spawn.
Future<void> respawnForWorkspace(String cwd) async {
final targets = _sessions.values.where((s) => s.cwd == cwd && s.memberName == null && s.forkSourceSessionId == null).toList();
for (final s in targets) {
final spec = SpawnSpec(
id: s.id,
role: s.role,
sessionId: s.sessionId,
cwd: s.cwd,
resume: true,
transcriptPath: claudeTranscriptPath(s.cwd, s.sessionId),
visible: s.visible,
);
await close(s.id);
await spawn(spec);
}
}
/// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
+68 -1
View File
@@ -178,7 +178,15 @@ final class AssistantToolUse extends ConversationItem {
/// driver has already resolved (workspace-relative paths are resolved before
/// injection); [caption] is an optional one-line label.
final class ImageMessage extends ConversationItem {
const ImageMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.path, this.caption});
const ImageMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
required this.path,
this.caption,
this.label,
this.description,
});
/// Absolute path to the image file on disk.
final String path;
@@ -186,10 +194,69 @@ final class ImageMessage extends ConversationItem {
/// Optional caption shown under the image.
final String? caption;
/// Optional richer annotations from a `--file` metadata payload (T-316): a
/// title/label above the image and a longer description beneath it.
final String? label, description;
@override
String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})';
}
/// A locally-injected drawing card (T-318). Not parsed from the transcript —
/// driven into the conversation by `clide draw --file <doc>` (D-6 parity) and
/// rendered display-only per D-78. [svg] is the SVG substrate the renderer
/// paints (already lowered from the doc's template / primitive source);
/// [label] / [description] are the optional card caption.
final class DrawingMessage extends ConversationItem {
const DrawingMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
required this.svg,
this.label,
this.description,
this.source,
});
/// The SVG document source the renderer paints.
final String svg;
/// Optional card-level caption (label + supporting description).
final String? label, description;
/// Optional template source (e.g. the d2 diagram text) — shown in a collapsed
/// "view source" disclosure on the card when present (T-494).
final String? source;
@override
String toString() => 'DrawingMessage(${label ?? '<svg>'})';
}
/// One glyph entry on an [IconMessage] (T-313): a resolved Phosphor [codepoint]
/// (its [name] kept for copy/debug), with optional per-entry [label],
/// [description], and [color] (hex or CSS name, parsed at render).
final class IconEntry {
const IconEntry({required this.codepoint, required this.name, this.label, this.description, this.color});
final int codepoint;
final String name;
final String? label, description, color;
}
/// A locally-injected Phosphor glyph card (T-313). Driven by `clide icon show`
/// (D-6 parity), display-only per D-78. Renders each [entries] glyph at a hero
/// size plus a sample strip of real UI sizes, with its optional label +
/// description; [color] is the card-level default glyph color.
final class IconMessage extends ConversationItem {
const IconMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.entries, this.color});
final List<IconEntry> entries;
final String? color;
@override
String toString() => 'IconMessage(${entries.length} glyph${entries.length == 1 ? '' : 's'})';
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
+15 -1
View File
@@ -16,6 +16,7 @@ class MarkdownExtension extends ClideExtension {
List<String> get dependsOn => const [];
StreamSubscription<Message>? _sub;
StreamSubscription<DaemonEvent>? _editorSub;
@override
List<ContributionPoint> get contributions => [
@@ -38,8 +39,21 @@ class MarkdownExtension extends ClideExtension {
if (msg.data['path'] is! String) return;
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
});
// Live-sync read-mirror (T-36, D-50 behavior 4): opening a renderable .md in
// the editor auto-reveals the reader, which then mirrors the buffer live.
// Non-renderable files reveal nothing (D-50 behavior 5).
_editorSub = ctx.events.on<DaemonEvent>().listen((e) {
if (e.subsystem != 'editor' || e.kind != 'editor.opened') return;
final path = e.data['path'] as String?;
if (path != null && isRenderableMarkdownPath(path)) {
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
}
});
}
@override
Future<void> deactivate() async => _sub?.cancel();
Future<void> deactivate() async {
await _sub?.cancel();
await _editorSub?.cancel();
}
}
+78 -2
View File
@@ -5,6 +5,13 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Whether [path] is a markdown file the reader can mirror live (T-36, D-50).
/// Non-renderable files get no auto-viewer (D-50 behavior 5).
bool isRenderableMarkdownPath(String path) {
final p = path.toLowerCase();
return p.endsWith('.md') || p.endsWith('.markdown');
}
class MarkdownViewer extends StatefulWidget {
const MarkdownViewer({super.key});
@@ -17,8 +24,15 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
String? _content;
String? _error;
StreamSubscription<Message>? _selectionSub;
StreamSubscription<DaemonEvent>? _editorSub;
ReaderNav? _nav;
/// Live-sync mirror state (T-36, D-50 behavior 4): when [_mirror] is true the
/// view is a read-only reflection of the open editor buffer [_mirrorId] and
/// re-reads it on every edit, rather than a disk snapshot.
bool _mirror = false;
String? _mirrorId;
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -31,12 +45,68 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
final path = msg.data['path'] as String?;
if (path != null) _loadFile(path);
});
// Live-sync: mirror the editor buffer as it's typed (T-36).
_editorSub = kernel.events.on<DaemonEvent>().listen(_onEditorEvent);
// The editor may have opened before this viewer mounted (the extension
// reveals the tab on editor.opened) — pick up the active buffer now.
unawaited(_mirrorActiveIfRenderable(kernel));
// Grab the latest entry the nav already holds (a selection that
// revealed this tab before we subscribed).
final current = _nav!.current;
if (current != null) _loadFile(current);
}
/// On mount, mirror the active editor buffer if it's a renderable file —
/// `editor.read` with no id resolves to the active buffer.
Future<void> _mirrorActiveIfRenderable(KernelServices kernel) async {
final resp = await kernel.ipc.request('editor.read', args: const {});
if (!mounted || !resp.ok) return;
final path = resp.data['path'] as String?;
final id = resp.data['id'] as String?;
if (path != null && id != null && isRenderableMarkdownPath(path)) {
_enterMirror(id, path, resp.data['content'] as String? ?? '');
}
}
void _onEditorEvent(DaemonEvent e) {
if (e.subsystem != 'editor') return;
final id = e.data['id'] as String?;
final path = e.data['path'] as String?;
switch (e.kind) {
case 'editor.opened':
if (id != null && path != null && isRenderableMarkdownPath(path)) _enterMirror(id, path, e.data['content'] as String? ?? '');
case 'editor.active-changed':
// Followed the active buffer: mirror a renderable one, drop the mirror
// for a non-renderable / no buffer (D-50 behavior 5 — no auto-viewer).
if (id != null && path != null && isRenderableMarkdownPath(path)) {
unawaited(_reread(id, path));
} else {
if (_mirror) setState(() => _mirror = false);
}
case 'editor.edited':
if (_mirror && id != null && id == _mirrorId && _path != null) unawaited(_reread(id, _path!));
case 'editor.closed':
if (id == _mirrorId && _mirror) setState(() => _mirror = false);
}
}
/// Re-read the in-memory buffer [id] and refresh the mirror (live edits).
Future<void> _reread(String id, String path) async {
final resp = await ClideKernel.of(context).ipc.request('editor.read', args: {'id': id});
if (!mounted || !resp.ok) return;
_enterMirror(id, path, resp.data['content'] as String? ?? '');
}
void _enterMirror(String id, String path, String content) {
setState(() {
_mirror = true;
_mirrorId = id;
_path = path;
_content = content;
_error = null;
});
}
void _onNavChanged() {
if (mounted) setState(() {}); // refresh action-bar button state
}
@@ -44,11 +114,14 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
@override
void dispose() {
_selectionSub?.cancel();
_editorSub?.cancel();
_nav?.removeListener(_onNavChanged);
super.dispose();
}
/// Fetch + display [path]. History lives in [ReaderNav]; never pushes.
/// Fetch + display [path] from disk. An explicit (agent-driven) selection, so
/// it leaves mirror mode — the edit affordance returns. History lives in
/// [ReaderNav]; never pushes.
Future<void> _loadFile(String path) async {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('files.read', args: {'path': path});
@@ -56,6 +129,7 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
if (resp.ok) {
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
setState(() {
_mirror = false;
_path = path;
_content = resp.data['content'] as String? ?? '';
_error = null;
@@ -121,7 +195,9 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
onEdit: _path != null ? _onEdit : null,
// Read-only while mirroring the live buffer (T-36): the file is already
// open in the editor, so no edit affordance here.
onEdit: (!_mirror && _path != null) ? _onEdit : null,
),
],
child: SingleChildScrollView(
+94 -1
View File
@@ -1,16 +1,24 @@
import 'dart:async';
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion, clideRepository, clideCommit, clideDate;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'licenses_loader.dart';
import 'update_check.dart';
/// The Help → About dialog (T-48): clide identity + build info, plus the
/// bundled-dependency licenses parsed from `assets/licenses.yaml`.
class AboutDialog extends StatelessWidget {
const AboutDialog({super.key, required this.onDismiss});
const AboutDialog({super.key, required this.onDismiss, this.updateFetch});
final VoidCallback onDismiss;
/// Injected fetch for the update check, so widget tests never touch the
/// network (T-47 P1). Production passes null → the real [githubGet].
@visibleForTesting
final GithubFetch? updateFetch;
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
@@ -52,6 +60,8 @@ class AboutDialog extends StatelessWidget {
value: clideRepository,
tokens: tokens,
),
const SizedBox(height: 14),
_UpdateCheckRow(tokens: tokens, fetch: updateFetch),
const SizedBox(height: 16),
ClideText(
ClideSettings.i18n.string(context, 'licenses.heading', namespace: 'builtin.menubar', placeholder: 'Bundled dependencies'),
@@ -76,6 +86,89 @@ class AboutDialog extends StatelessWidget {
}
}
/// "Check for updates" button + inline status (T-47 P1). The check runs only on
/// this explicit tap — never on launch, never on a timer (D-64 / POLICY.md).
class _UpdateCheckRow extends StatefulWidget {
const _UpdateCheckRow({required this.tokens, this.fetch});
final SurfaceTokens tokens;
final GithubFetch? fetch;
@override
State<_UpdateCheckRow> createState() => _UpdateCheckRowState();
}
class _UpdateCheckRowState extends State<_UpdateCheckRow> {
UpdateCheckResult? _result;
bool _checking = false;
Future<void> _check() async {
setState(() {
_checking = true;
_result = null;
});
final r = await checkForUpdate(repositoryUrl: clideRepository, currentVersion: clideVersion, fetch: widget.fetch ?? githubGet);
if (mounted) {
setState(() {
_checking = false;
_result = r;
});
}
}
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.menubar', placeholder: fallback);
@override
Widget build(BuildContext context) {
return Row(
children: [
ClideButton(label: _t('about.checkUpdates', 'Check for updates'), onPressed: _checking ? null : _check),
const SizedBox(width: 12),
Expanded(child: _status(context)),
],
);
}
Widget _status(BuildContext context) {
final tokens = widget.tokens;
if (_checking) return ClideText(_t('about.checking', 'Checking…'), fontSize: 12, color: tokens.globalTextMuted);
switch (_result) {
case null:
return const SizedBox.shrink();
case UpdateUpToDate():
return ClideText(_t('about.upToDate', "You're on the latest version."), fontSize: 12, color: tokens.globalTextMuted);
case UpdateAvailable(:final latest, :final url):
return Semantics(
button: true,
excludeSemantics: true,
label: 'clide $latest available — release notes',
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: () => unawaited(ClideKernel.of(context).os.openURL(url)),
builder: (ctx, hovered, _) => ClideText(
ClideSettings.i18n.interpolated(
context,
'about.updateAvailable',
namespace: 'builtin.menubar',
placeholder: 'clide {version} is available — release notes',
replacers: [I18nReplacer(from: '{version}', replace: latest)],
),
fontSize: 12,
color: tokens.globalFocus,
),
),
);
case UpdateCheckFailed(:final message):
return ClideText(
'${_t('about.updateFailed', "Couldn't check for updates")} ($message)',
fontSize: 12,
color: tokens.statusError,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
}
}
/// A label/value row in the build-info block.
class _Kv extends StatelessWidget {
const _Kv({required this.label, required this.value, required this.tokens});
+104
View File
@@ -0,0 +1,104 @@
/// Manual "check for updates" logic for the About dialog (T-47 P1, story T-46).
///
/// POLICY-sensitive: this is clide's ONLY outbound HTTP call, and it runs ONLY
/// on explicit user action (the About-box button) — never on a launch path, never
/// on a timer. It sends NO data about the user (a plain GET to the GitHub Releases
/// API), so it doesn't offend D-64's no-telemetry commitment. A background/periodic
/// poll would need a deliberate D-64 amendment first and is deferred.
library;
import 'dart:convert';
import 'dart:io';
/// Injectable GET → response body (throws on failure). Lets the check run
/// against a fake in tests so no widget test touches the network.
typedef GithubFetch = Future<String> Function(Uri url);
sealed class UpdateCheckResult {
const UpdateCheckResult();
}
/// Already on (or ahead of) the latest published release.
class UpdateUpToDate extends UpdateCheckResult {
const UpdateUpToDate(this.current);
final String current;
}
/// A newer release is available.
class UpdateAvailable extends UpdateCheckResult {
const UpdateAvailable({required this.latest, required this.url});
final String latest;
final String url;
}
/// The check couldn't complete (offline, API error, parse failure). The app is
/// fully functional regardless — the failure is surfaced, never silent.
class UpdateCheckFailed extends UpdateCheckResult {
const UpdateCheckFailed(this.message);
final String message;
}
/// clide's only outbound HTTP — a plain GET, identifying as `clide`, no body.
Future<String> githubGet(Uri url) async {
final client = HttpClient();
try {
final req = await client.getUrl(url);
req.headers.set(HttpHeaders.userAgentHeader, 'clide');
req.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json');
final resp = await req.close();
if (resp.statusCode != 200) throw HttpException('HTTP ${resp.statusCode}');
return resp.transform(utf8.decoder).join();
} finally {
client.close();
}
}
/// Pull `owner/repo` from a GitHub URL (`https://github.com/owner/repo[.git]`).
({String owner, String repo})? parseGithubRepo(String repositoryUrl) {
final m = RegExp(r'github\.com[/:]([^/]+)/([^/.\s]+)').firstMatch(repositoryUrl);
return m == null ? null : (owner: m.group(1)!, repo: m.group(2)!);
}
/// Fetch the latest GitHub Release for [repositoryUrl] and compare its version
/// to [currentVersion]. Never throws — failures come back as [UpdateCheckFailed].
Future<UpdateCheckResult> checkForUpdate({required String repositoryUrl, required String currentVersion, GithubFetch fetch = githubGet}) async {
final gh = parseGithubRepo(repositoryUrl);
if (gh == null) return const UpdateCheckFailed('unrecognized repository URL');
try {
final body = await fetch(Uri.parse('https://api.github.com/repos/${gh.owner}/${gh.repo}/releases/latest'));
final json = jsonDecode(body) as Map<String, Object?>;
final tag = (json['tag_name'] as String?)?.trim() ?? '';
final latest = tag.startsWith('v') ? tag.substring(1) : tag;
if (latest.isEmpty) return const UpdateCheckFailed('no release version found');
final url = (json['html_url'] as String?) ?? repositoryUrl;
return compareSemver(latest, currentVersion) > 0 ? UpdateAvailable(latest: latest, url: url) : UpdateUpToDate(currentVersion);
} catch (e) {
return UpdateCheckFailed('$e');
}
}
/// Minimal semver compare → -1/0/1 for a<b / a==b / a>b. Compares
/// major.minor.patch numerically (so 2.3.10 > 2.3.9), and ranks a pre-release
/// BELOW the same release (2.8.2-rc < 2.8.2). Missing components count as 0.
int compareSemver(String a, String b) {
(List<int>, String) parse(String v) {
final dash = v.indexOf('-');
final core = dash >= 0 ? v.substring(0, dash) : v;
final pre = dash >= 0 ? v.substring(dash + 1) : '';
final nums = [for (final p in core.split('.')) int.tryParse(p.trim()) ?? 0];
while (nums.length < 3) {
nums.add(0);
}
return (nums, pre);
}
final (an, ap) = parse(a);
final (bn, bp) = parse(b);
for (var i = 0; i < 3; i++) {
if (an[i] != bn[i]) return an[i] < bn[i] ? -1 : 1;
}
if (ap.isEmpty && bp.isEmpty) return 0;
if (ap.isEmpty) return 1; // release outranks a pre-release of the same core
if (bp.isEmpty) return -1;
return ap.compareTo(bp);
}
@@ -8,6 +8,7 @@ library;
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/env/supporter_binaries.dart';
import 'package:flutter/foundation.dart';
class Problem {
@@ -69,9 +70,24 @@ class ProblemsController extends ChangeNotifier {
}
}
found.addAll(supporterToolProblems(activeSupporterBinaries));
_loading = false;
_error = null;
_problems = found;
notifyListeners();
}
}
/// Problems for STALE supporter-binary pins (T-495 / D-104): an explicit tool
/// path that no longer points at a file — a real misconfig (a tool moved on an
/// upgrade). A merely-unfound optional tool isn't flagged; its use-time
/// userError covers that. Pure + testable.
List<Problem> supporterToolProblems(SupporterBinaries? tools) {
if (tools == null) return const [];
return [
for (final name in knownSupporterTools)
if (tools.isStalePin(name))
Problem(source: 'tools', message: 'configured path for "$name" is missing', hint: 'Re-detect, or update its path in settings (app.tools).'),
];
}
+22 -5
View File
@@ -18,7 +18,17 @@ import 'package:clide/src/terminal/terminal.dart';
/// job. The shared widget layer (ClidePtyView, ClidePaneChrome) keeps
/// the two extensions visually consistent without coupling them.
class TerminalPane extends StatefulWidget {
const TerminalPane({super.key});
const TerminalPane({super.key, this.argv, this.env, this.cwdOverride});
/// Command to run instead of the login `$SHELL` — e.g. `['claude', 'login']`
/// for the per-repo account sign-in pane (T-485). Null spawns the shell.
final List<String>? argv;
/// Extra environment for the spawned process (e.g. `CLAUDE_CONFIG_DIR`).
final Map<String, String>? env;
/// Working directory override; defaults to the open workspace.
final String? cwdOverride;
@override
State<TerminalPane> createState() => _TerminalPaneState();
@@ -79,16 +89,23 @@ class _TerminalPaneState extends State<TerminalPane> {
// Windows has no $SHELL convention and no login-shell flag —
// PowerShell 7 first, classic PowerShell as the always-there
// fallback.
// fallback. A caller-supplied argv (e.g. `claude login`, T-485) wins.
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
final argv = widget.argv ?? (shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo']);
// The open workspace, not Directory.current — a desktop launch starts
// in $HOME and a project switch doesn't move the process CWD (T-381).
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
final cwd = widget.cwdOverride ?? _kernel?.project.current?.path ?? Directory.current.path;
final response = await ipc.request(
'pane.spawn',
args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight},
args: {
'argv': argv,
'kind': PaneKind.terminal.wire,
'cwd': cwd,
'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight,
if (widget.env != null) 'env': widget.env,
},
);
if (!mounted) return;
if (!response.ok) {
@@ -0,0 +1,112 @@
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/env/supporter_binaries.dart';
/// Settings surface for the external supporter binaries clide shells out to —
/// 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.
class ToolsSettingsExtension extends ClideExtension {
@override
String get id => 'builtin.tools-settings';
@override
String get title => 'Tool paths';
@override
String get version => '0.1.0';
ClideExtensionContext? _ctx;
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
ctx.settings.addListener(_sync);
}
@override
Future<void> deactivate() async {
_ctx?.settings.removeListener(_sync);
}
/// Rebuild the process-wide resolver from the current override keys so a path
/// edited in the settings panel applies live. Cheap — reads a couple of keys.
void _sync() {
final ctx = _ctx;
if (ctx == null) return;
activeSupporterBinaries = supporterBinariesFrom((k) => ctx.settings.get<Object>(k));
}
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'tools.detect',
command: 'tools.detect',
title: 'Re-detect tool paths',
titleKey: 'command.detect',
i18nNamespace: id,
run: _redetect,
),
SettingsCategoryContribution(
id: 'tools',
category: SettingsCategory(
id: 'tools',
title: 'Tools',
titleKey: 'settings.title',
i18nNamespace: id,
iconName: 'wrench',
priority: 60,
sections: [
SettingsSection(
label: 'Supporter binaries',
labelKey: 'settings.section.binaries',
fields: [
SettingsField(
key: supporterToolKey('claude'),
kind: SettingsFieldKind.text,
label: 'Claude CLI',
labelKey: 'settings.field.claude.label',
help: 'Absolute path to the claude binary; blank to auto-resolve.',
helpKey: 'settings.field.claude.help',
),
SettingsField(
key: supporterToolKey('d2'),
kind: SettingsFieldKind.text,
label: 'd2',
labelKey: 'settings.field.d2.label',
help: 'Absolute path to the d2 diagram compiler; blank to auto-resolve.',
helpKey: 'settings.field.d2.help',
),
SettingsField(
key: 'app.tools.redetect',
kind: SettingsFieldKind.file,
label: 'Re-detect',
labelKey: 'settings.field.detect.label',
help: 'Re-scan PATH and the common install dirs, overwriting the paths above.',
helpKey: 'settings.field.detect.help',
fileCommand: 'tools.detect',
),
],
),
],
),
),
];
Future<IpcResponse> _redetect(List<String> args) async {
final ctx = _ctx;
if (ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'tools-settings not activated'),
);
}
final resolver = await redetectSupporterBinaries(write: (k, v) => ctx.settings.set<Object?>(k, v));
activeSupporterBinaries = resolver;
return IpcResponse.ok(
id: '',
data: {
'detected': {for (final t in knownSupporterTools) t: resolver.resolve(t)},
},
);
}
}
@@ -0,0 +1 @@
export 'src/extension.dart';
+228 -12
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter/widgets.dart';
@@ -198,10 +199,20 @@ class _StartColumn extends StatelessWidget {
tokens: tokens,
onTap: () => _openFolder(context),
),
_ActionRow(
icon: PhosphorIcons.byName('folder-plus'),
label: ClideSettings.i18n.string(context, 'action.newProject', namespace: 'builtin.welcome', placeholder: 'New project…'),
tokens: tokens,
onTap: () => _newProject(context),
),
],
);
}
void _newProject(BuildContext context) {
kernel.dialog.show<Object>((ctx, dismiss) => _NewProjectDialog(kernel: kernel, onClose: () => dismiss()));
}
void _openFolder(BuildContext context) async {
try {
final picked = await kernel.window.pickDirectory();
@@ -210,7 +221,7 @@ class _StartColumn extends StatelessWidget {
if (ok) {
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else {
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(kernel: kernel, path: picked, onDismiss: () => dismiss()));
}
}
return;
@@ -630,16 +641,166 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
}
}
class _NotARepoDialog extends StatelessWidget {
const _NotARepoDialog({required this.path, required this.onDismiss});
/// Shown when the opened folder isn't a git repo. Rather than a dead end, it
/// offers to initialize the folder as a clide project (T-489): `project.init`,
/// open, and announce on [projectCreatedChannel] so the account roadblock fires
/// — the same path a brand-new project takes.
class _NotARepoDialog extends StatefulWidget {
const _NotARepoDialog({required this.kernel, required this.path, required this.onDismiss});
final KernelServices kernel;
final String path;
final VoidCallback onDismiss;
@override
State<_NotARepoDialog> createState() => _NotARepoDialogState();
}
class _NotARepoDialogState extends State<_NotARepoDialog> {
bool _loading = false;
String? _error;
Future<void> _initialize() async {
setState(() {
_loading = true;
_error = null;
});
final r = await widget.kernel.ipc.request(
'project.init',
args: {
'positional': <String>[],
'flags': {'dir': widget.path},
},
);
if (!mounted) return;
if (!r.ok) {
return setState(() {
_loading = false;
_error = r.error?.message ?? 'Could not initialize this folder.';
});
}
final opened = await widget.kernel.project.open(widget.path);
if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': widget.path});
widget.onDismiss();
}
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.welcome', placeholder: fallback);
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Container(
width: 420,
width: 460,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(_t('dialog.notRepo.title', 'No git repo found'), fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
const SizedBox(height: 8),
ClideText(widget.path, muted: true, fontSize: clideFontMeta),
const SizedBox(height: 8),
ClideText(_t('dialog.notRepo.body', 'A clide project needs a git repository. Initialize this folder as one?'), muted: true, fontSize: clideFontMeta),
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: _t('button.cancel', 'Cancel'), onPressed: widget.onDismiss),
const SizedBox(width: 8),
ClideButton(
label: _loading ? _t('button.initializing', 'Initializing…') : _t('dialog.notRepo.initialize', 'Initialize project'),
onPressed: _loading ? null : _initialize,
),
],
),
],
),
);
}
}
/// New-project dialog (T-488, story T-486): pick a location + name, create the
/// project via `project.new`, open it, and announce it on [projectCreatedChannel]
/// so the Claude extension can run the per-repo account roadblock. Stays
/// claude-free — the account step is the consumer's job, not this dialog's.
class _NewProjectDialog extends StatefulWidget {
const _NewProjectDialog({required this.kernel, required this.onClose});
final KernelServices kernel;
final VoidCallback onClose;
@override
State<_NewProjectDialog> createState() => _NewProjectDialogState();
}
class _NewProjectDialogState extends State<_NewProjectDialog> {
final TextEditingController _parent = TextEditingController();
final TextEditingController _name = TextEditingController();
final FocusNode _parentFocus = FocusNode(debugLabel: 'new-project-parent');
final FocusNode _nameFocus = FocusNode(debugLabel: 'new-project-name');
String? _error;
bool _loading = false;
@override
void dispose() {
_parent.dispose();
_name.dispose();
_parentFocus.dispose();
_nameFocus.dispose();
super.dispose();
}
Future<void> _browse() async {
try {
final picked = await widget.kernel.window.pickDirectory();
if (picked != null && mounted) setState(() => _parent.text = picked);
} on MissingPluginException {
// No native picker on this platform — the user types the path instead.
}
}
Future<void> _create() async {
final name = _name.text.trim();
final parent = _parent.text.trim();
if (name.isEmpty) return setState(() => _error = 'Enter a project name.');
if (parent.isEmpty) return setState(() => _error = 'Choose a location.');
setState(() {
_loading = true;
_error = null;
});
final r = await widget.kernel.ipc.request(
'project.new',
args: {
'positional': [name],
'flags': {'dir': parent},
},
);
if (!mounted) return;
if (!r.ok) {
return setState(() {
_loading = false;
_error = r.error?.message ?? 'Could not create the project.';
});
}
final path = r.data['path'] as String;
// Open the new workspace, then announce it — only a freshly-created project
// announces, so only it triggers the account roadblock (T-488).
final opened = await widget.kernel.project.open(path);
if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': path});
widget.onClose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Container(
width: 460,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
@@ -651,30 +812,46 @@ class _NotARepoDialog extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
ClideSettings.i18n.string(context, 'dialog.notRepo.title', namespace: 'builtin.welcome', placeholder: 'No git repo found'),
ClideSettings.i18n.string(context, 'dialog.newProject.title', namespace: 'builtin.welcome', placeholder: 'New project'),
fontSize: clideFontDialogTitle,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: clideFontMeta),
const SizedBox(height: 8),
const SizedBox(height: 4),
ClideText(
ClideSettings.i18n.string(
context,
'dialog.notRepo.body',
'dialog.newProject.body',
namespace: 'builtin.welcome',
placeholder: 'A clide project root requires a git repository.',
placeholder: 'Creates a git repo + a CLAUDE.md, then opens it.',
),
muted: true,
fontSize: clideFontMeta,
),
const SizedBox(height: 16),
_field(
tokens,
'Location',
_parent,
_parentFocus,
trailing: ClideButton(label: 'Browse…', onPressed: _browse),
),
const SizedBox(height: 10),
_field(tokens, 'Name', _name, _nameFocus, onSubmit: _create),
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.welcome', placeholder: 'OK'),
onPressed: () => onDismiss(),
label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.welcome', placeholder: 'Cancel'),
onPressed: widget.onClose,
),
const SizedBox(width: 8),
ClideButton(
label: _loading
? ClideSettings.i18n.string(context, 'button.creating', namespace: 'builtin.welcome', placeholder: 'Creating…')
: ClideSettings.i18n.string(context, 'button.create', namespace: 'builtin.welcome', placeholder: 'Create'),
onPressed: _loading ? null : _create,
),
],
),
@@ -682,4 +859,43 @@ class _NotARepoDialog extends StatelessWidget {
),
);
}
Widget _field(SurfaceTokens tokens, String label, TextEditingController c, FocusNode f, {Widget? trailing, Future<void> Function()? onSubmit}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(label, fontSize: clideFontMeta, muted: true),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: f.hasFocus ? tokens.panelActiveBorder : tokens.globalBorder),
borderRadius: BorderRadius.circular(4),
),
child: EditableText(
controller: c,
focusNode: f,
style: TextStyle(
color: tokens.globalForeground,
fontSize: clideFontCaption,
fontFamily: ClideSettings.fonts.monoOf(context),
fontFamilyFallback: clideMonoFamilyFallback,
),
cursorColor: tokens.globalForeground,
backgroundCursorColor: tokens.globalTextMuted,
maxLines: 1,
onSubmitted: onSubmit == null ? null : (_) => unawaited(onSubmit()),
),
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing],
],
),
],
);
}
}
+2
View File
@@ -45,6 +45,7 @@ export 'src/focus.dart';
export 'src/i18n/catalog_loader.dart';
export 'src/i18n/fallback_chain.dart';
export 'src/i18n/i18n.dart';
export 'src/i18n/tier0_namespaces.dart';
export 'src/net.dart';
export 'src/notify.dart';
export 'src/os.dart';
@@ -63,6 +64,7 @@ export 'src/panels/layout_preset.dart';
export 'src/panels/registry.dart';
export 'src/panels/slot_id.dart';
export 'src/panels/view_pane_snapshot.dart';
export 'src/theme/bundled_themes.dart';
export 'src/theme/contrast.dart';
export 'src/theme/controller.dart';
export 'src/theme/loader.dart';
+10 -3
View File
@@ -92,10 +92,15 @@ class I18n extends ChangeNotifier {
/// Look up a key, walking the locale fallback chain. Returns the
/// placeholder if nothing hits; returns the key itself when placeholder
/// is null (developer fallback — keys are more useful than blanks).
String string(String key, {required String namespace, String? placeholder}) {
/// [warnIfMissing] false suppresses the missing-key warning for OPEN-ENDED
/// lookups where a miss is the normal case, not a bug — e.g. tool display
/// names (`tool.name.Bash`, `tool.name.ScheduleWakeup`, MCP tools), which are
/// proper-name identifiers that intentionally fall back to the raw name. Keep
/// the warning on for fixed UI strings, where a miss is a real translation gap.
String string(String key, {required String namespace, String? placeholder, bool warnIfMissing = true}) {
final byLocale = _cache[namespace];
if (byLocale == null) {
_warnOnce('$namespace::MISSING_NAMESPACE::$key', 'i18n: namespace not registered: $namespace (key: $key)');
if (warnIfMissing) _warnOnce('$namespace::MISSING_NAMESPACE::$key', 'i18n: namespace not registered: $namespace (key: $key)');
return placeholder ?? key;
}
@@ -108,7 +113,9 @@ class I18n extends ChangeNotifier {
if (hit != null) return hit;
}
_warnOnce('$namespace::${_current.languageCode}::$key', 'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})');
if (warnIfMissing) {
_warnOnce('$namespace::${_current.languageCode}::$key', 'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})');
}
return placeholder ?? key;
}
+25
View File
@@ -0,0 +1,25 @@
/// Canonical list of Tier-0 i18n namespaces preloaded at boot — the single
/// source of truth (T-371).
///
/// `lib/main.dart` preloads exactly these (framework chrome under `core`, plus
/// the catalogs of the built-ins that activate at Tier 0); the i18n coverage
/// gate validates every one of them. The other ~17 catalogs that ship belong
/// to built-ins that activate lazily in later tiers — their catalogs load on
/// activation, and the gate validates them through the assets-dir sweep rather
/// than this preload list.
library;
/// Namespaces whose catalogs are loaded at boot, before any extension that
/// owns them has activated. `core` holds framework chrome that lives outside
/// any extension (lib/widgets, lib/kernel, shared reader chrome — T-469).
const List<String> kTier0Namespaces = [
'core',
'builtin.default-layout',
'builtin.welcome',
'builtin.ipc-status',
'builtin.theme-picker',
'builtin.terminal',
'builtin.files',
'builtin.claude',
'builtin.editor',
];
+8 -3
View File
@@ -15,11 +15,16 @@ class OsLifecycleEvent extends ClideEvent {
String get kind => _kind;
}
/// Runs an external command — injected so [OsBridge] is testable without
/// spawning a real `xdg-open` / `open` / `explorer`.
typedef OsProcessRunner = Future<ProcessResult> Function(String executable, List<String> arguments);
class OsBridge {
OsBridge({required Logger log, required DaemonBus events}) : _log = log, _events = events;
OsBridge({required Logger log, required DaemonBus events, OsProcessRunner? run}) : _log = log, _events = events, _run = run ?? Process.run;
final Logger _log;
final DaemonBus _events;
final OsProcessRunner _run;
Future<bool> openURL(String url) async {
final cmd = _openCommand();
@@ -28,7 +33,7 @@ class OsBridge {
return false;
}
try {
final r = await Process.run(cmd[0], [...cmd.skip(1), url]);
final r = await _run(cmd[0], [...cmd.skip(1), url]);
return r.exitCode == 0;
} catch (e) {
_log.warn('os', 'openURL failed', error: e);
@@ -40,7 +45,7 @@ class OsBridge {
final cmd = _revealCommand(path);
if (cmd == null) return false;
try {
final r = await Process.run(cmd[0], cmd.skip(1).toList());
final r = await _run(cmd[0], cmd.skip(1).toList());
return r.exitCode == 0;
} catch (e) {
_log.warn('os', 'reveal failed', error: e);
+9
View File
@@ -113,6 +113,15 @@ class SettingsStore extends ChangeNotifier {
SettingsScope.ext => null,
};
/// Every key currently stored in [layer] (no cross-layer merge) — for
/// prefix-scan consumers like the per-workspace account-binding enumerator
/// (T-480). [SettingsScope.ext] is a key class, not a layer → empty.
Iterable<String> keysAt(SettingsScope layer) => switch (layer) {
SettingsScope.app => _appValues.keys,
SettingsScope.project => _projectValues.keys,
SettingsScope.ext => const <String>[],
};
/// The storage layer currently supplying [key]'s value (project overrides app
/// for `ext.*`), or null when unset (Default). Honors the key's prefix.
SettingsScope? effectiveLayer(String key) {
+38
View File
@@ -0,0 +1,38 @@
/// Canonical list of the bundled theme asset paths — the single source of
/// truth for which themes ship (T-371).
///
/// Three consumers used to hand-maintain their own copy and drifted apart
/// (main.dart loaded 10, the testmode harness loaded 8 — catppuccin was
/// silently unvalidated, and the WCAG contrast gate had a third copy):
///
/// - `lib/main.dart` `_loadBundledThemes()` — what the running app loads.
/// - `lib/test_app.dart` — the testmode platform-integration harness.
/// - `test/a11y/contrast_test.dart` — the WCAG-AA contrast gate.
///
/// They now all iterate this list, so adding a theme here loads it, validates
/// it, and ships it everywhere at once. The contrast gate additionally asserts
/// every `.yaml` under [kThemesDir] is in this list, so a new theme cannot sit
/// on disk (or ship) unvalidated.
library;
/// Directory holding the theme YAML sources, relative to the repo root.
const String kThemesDir = 'lib/kernel/src/theme/themes';
/// Theme YAML asset paths bundled into the app, in display order: the base
/// themes, then their `-hc` high-contrast siblings, then third-party / ported
/// palettes paired with their own `-hc` siblings. Every base theme has a
/// structurally identical `-hc` sibling that clears the strict contrast gate.
const List<String> kBundledThemePaths = [
'$kThemesDir/clide.yaml',
'$kThemesDir/midnight.yaml',
'$kThemesDir/paper.yaml',
'$kThemesDir/terminal.yaml',
'$kThemesDir/clide-hc.yaml',
'$kThemesDir/midnight-hc.yaml',
'$kThemesDir/paper-hc.yaml',
'$kThemesDir/terminal-hc.yaml',
'$kThemesDir/catppuccin-mocha.yaml',
'$kThemesDir/catppuccin-mocha-hc.yaml',
'$kThemesDir/summer-night.yaml',
'$kThemesDir/summer-night-hc.yaml',
];
@@ -0,0 +1,44 @@
# summer-night-hc — high-contrast sibling of `summer-night`.
#
# Same cyan/teal/pink silhouette and identical schema; muted text, status
# chips, syntax tokens, and the focus border are brightened to clear the
# strict contrast gate (D-22 + D-69, `extendedPairs` in
# lib/kernel/src/theme/contrast.dart). Backgrounds/surface/foreground are
# unchanged from the base so the two read as the same theme.
name: summer-night-hc
display_name: Summer Night (high contrast)
dark: true
palette:
primary: "#5cc8ec"
secondary: "#5fd6cb"
accent: "#ff84a6"
background: "#21262f"
surface: "#393e48"
panel: "#292e38"
foreground: "#e2e8f5"
muted: "#c4cad6"
success: "#3fd6c0"
warning: "#e6b072"
error: "#ff9b99"
info: "#6cc8ec"
surfaceHi: "#434a57"
border: "#3a414d"
borderHi: "#5cc8ec"
textDim: "#c4cad6"
textMute: "#9aa2b2"
accentSoft: "#21ff84a6"
syntax:
keyword: "#ffa0c0" # pink
type: "#6cd4f0" # cyan
string: "#9fe0c0" # green
number: "#f0c888" # amber
comment: "#9aa2b2" # muted
method: "#80d0e8" # teal-cyan
punct: "#c4cad6" # dim
+29 -9
View File
@@ -1,24 +1,27 @@
# Summer Night — ported from legacy clide v1.2.0.
# Palette-only; the three-tier resolver fills semantic + surface from
# defaults. Override sections land here as the token surface grows.
# Summer Night — cyan/teal/pink on blue-grey (ported from legacy clide v1.2.0).
#
# The 12 original palette colors are kept as the v1.2 designer chose them. The
# expanded keys (surfaceHi, border/borderHi, textDim/textMute, accentSoft) and
# the syntax set follow the clide derivation pattern, re-tinted to this theme's
# identity. Base honours the design and clears the canonical contrast gate;
# the high-contrast sibling (summer-night-hc) clears the strict extended gate.
name: summer-night
display_name: Summer Night
dark: true
palette:
# Accents (legacy names: primary/secondary/accent)
primary: "#00a3d2" # cyan
secondary: "#00a9b9" # teal
accent: "#fa5f8b" # pink
# Accents (legacy: primary cyan / secondary teal / accent pink)
primary: "#00a3d2"
secondary: "#00a9b9"
accent: "#fa5f8b"
# Backgrounds
background: "#21262f"
surface: "#393e48"
panel: "#292e38"
# Text. `muted` is WCAG-AA-calibrated against `panel` — don't darken
# without re-running the a11y/contrast suite.
# Text. `muted` is WCAG-AA-calibrated against `panel`.
foreground: "#e2e8f5"
muted: "#a6adbb"
@@ -27,3 +30,20 @@ palette:
warning: "#d08447"
error: "#f06c6f"
info: "#00a3d2"
# Expanded keys (clide pattern, summer-night tint)
surfaceHi: "#434a57"
border: "#3a414d"
borderHi: "#4a5260"
textDim: "#9aa2b2"
textMute: "#727a8a"
accentSoft: "#21fa5f8b"
syntax:
keyword: "#fa8fb0" # pink
type: "#5ec8e6" # cyan
string: "#7fd3a8" # green
number: "#e0b070" # amber
comment: "#727a8a" # muted
method: "#6fc6e0" # teal-cyan
punct: "#9aa2b2" # dim
+156 -33
View File
@@ -27,19 +27,30 @@ import 'package:clide/builtin/problems/problems.dart';
import 'package:clide/builtin/settings_ui/settings_ui.dart';
import 'package:clide/builtin/terminal/terminal.dart';
import 'package:clide/builtin/theme_picker/theme_picker.dart';
import 'package:clide/builtin/tools_settings/tools_settings.dart';
import 'package:clide/builtin/view/view.dart';
import 'package:clide/builtin/vim/vim.dart';
import 'package:clide/builtin/tickets/tickets.dart';
import 'package:clide/builtin/todos/todos.dart';
import 'package:clide/builtin/welcome/welcome.dart';
import 'dart:io' show Directory, File, Platform;
import 'dart:io' show Directory, File, Platform, pid;
import 'package:clide/kernel/kernel.dart';
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/draw_commands.dart';
import 'package:clide/src/draw/compare_template.dart' show compareTemplateHandler;
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
import 'package:clide/src/draw/graph_template.dart' show graphTemplateHandler;
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/icon_commands.dart';
import 'package:clide/src/daemon/image_commands.dart';
import 'package:clide/src/daemon/project_commands.dart';
import 'package:clide/src/daemon/instance_command.dart';
import 'package:clide/src/daemon/log_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/status_command.dart';
@@ -52,6 +63,8 @@ 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/supporter_binaries.dart';
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart' show kPhosphorGlyphs;
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/mcp_server.dart';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
@@ -108,6 +121,10 @@ Future<void> main() async {
await primeLoginShellPath();
final bootSettings = SettingsStore(appDir: appDir);
await bootSettings.load();
// Resolve external supporter binaries (claude, d2, …) — explicit user-scope
// overrides first, else the primed PATH + the well-known dirs; auto-detect
// and pin them on first run (T-495, D-104).
activeSupporterBinaries = await loadSupporterBinaries(read: (k) => bootSettings.get<Object>(k), write: (k, v) => bootSettings.set<Object?>(k, v));
startupWorkRoot = resolveStartupWorkspace(
cwdRoot: startupWorkRoot,
lastProject: bootSettings.get<String>('app.lastProject'),
@@ -227,7 +244,19 @@ Future<void> main() async {
ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st);
return;
}
final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog, dispatcher: dispatcher);
final mcp = McpServer(
workspaceRoot: workRoot.path,
log: ipcLog,
dispatcher: dispatcher,
// T-479: when this workspace is bound to a Claude account, also write the
// /ide discovery lock into that account's config dir so a `claude` started
// with CLAUDE_CONFIG_DIR=<dir> can reach clide's bridge. Lazy — resolved
// post-boot once kernelSettings (and the registry) exist.
boundConfigDir: () {
final s = kernelSettings;
return s == null ? null : AccountRegistry(s).accountForWorkspace(workRoot.path)?.dir;
},
);
mcpServer = mcp;
try {
await mcp.start();
@@ -286,6 +315,10 @@ Future<void> main() async {
// visible to `pane list` by snapshotting the kernel PanelRegistry +
// LayoutArrangement at request time — no mirrored state to drift.
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
// `clide instance` — this instance's identity (version/pid/workspace/socket)
// so `clide instances` can list every live instance and a human/agent can
// tell which one a socket belongs to (T-247).
registerInstanceCommand(dispatcher, version: clideVersion, pid: pid, workspace: workRoot.path, socketPath: workspaceSocketPath(workRoot.path));
// `clide log level [<level>]` — the live verbosity toggle's CLI half (T-433,
// D-6 parity with the output-dock Level chip). Persists via the kernel
// settings, captured post-boot.
@@ -312,6 +345,15 @@ Future<void> main() async {
registerEditorCommands(dispatcher, editorRegistry);
final gitClient = GitClient(toolchain: tc, workDir: workRoot);
registerGitCommands(dispatcher, gitClient, eventSink);
// `clide project new <name>` (T-487): create + git-init a new project dir.
// git init runs over the *new* dir via the toolchain; --dir defaults to the
// current workspace's parent so a new project lands beside this one.
registerProjectCommands(
dispatcher,
gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(),
defaultParent: () => workRoot.parent.path,
defaultInitPath: () => workRoot.path,
);
final pql = PqlClient(workDir: workRoot, toolchain: tc);
registerPqlCommands(dispatcher, pql);
registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement));
@@ -330,6 +372,73 @@ Future<void> main() async {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
return file.existsSync() ? file.absolute.path : null;
},
readFile: (path) async {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
try {
return file.existsSync() ? await file.readAsString() : null;
} catch (_) {
return null;
}
},
);
// `clide icon show <name…>` — drive a Phosphor glyph card into the Claude
// conversation (T-313). Resolves names via the bundled glyph table.
registerIconCommands(
dispatcher,
() => kernelMessages?.publish,
resolve: (name) => kPhosphorGlyphs[name],
readFile: (path) async {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
try {
return file.existsSync() ? await file.readAsString() : null;
} catch (_) {
return null;
}
},
);
// `clide draw --file <doc>` — drive a drawing card into the Claude
// conversation (T-318, drive-half of D-6). Reads the JSON doc relative to
// workRoot, lowers it to SVG via the template registry (primitive svg now;
// d2/icon/compare/image handlers register as they land), then publishes a
// 'draw' message the Claude extension injects.
registerDrawCommands(
dispatcher,
() => kernelMessages?.publish,
registry: DrawingRegistry()
..register('d2', d2TemplateHandler())
..register('graph', graphTemplateHandler())
..register(
'compare',
compareTemplateHandler(
resolvePath: (path) {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
return file.existsSync() ? file.absolute.path : null;
},
),
),
readFile: (path) async {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
try {
return file.existsSync() ? await file.readAsString() : null;
} catch (_) {
return null;
}
},
);
// `clide claude account …` — manage per-repo Claude accounts (T-480, epic
// T-476). Registry reads/writes go through the user-scope SettingsStore;
// side-effects (respawn, login pane, --purge) are published on
// accountActionChannel for the Claude extension to perform.
registerClaudeAccountCommands(
dispatcher,
() {
final settings = kernelSettings;
final home = Platform.environment['HOME'];
if (settings == null || home == null || home.isEmpty) return null;
return _AccountStoreAdapter(AccountRegistry(settings), home);
},
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
@@ -393,7 +502,7 @@ Future<void> main() async {
appDir: appDir,
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: _tier0Namespaces,
preloadNamespaces: kTier0Namespaces,
// Languages the UI can switch to (Settings → Appearance, T-462). Each needs
// an assets/i18n/<locale>/ catalog folder; root_shell applies the persisted
// app.locale on boot.
@@ -446,6 +555,11 @@ Future<void> main() async {
kernelMessages = services.messages;
kernelFilterStates = services.filterStates;
kernelSettings = services.settings;
// T-479: the account registry is now resolvable (kernelSettings is set), so
// re-sync the /ide discovery locks to pick up any account bound to this
// workspace at boot, and re-sync on every account binding change.
unawaited(mcpServer?.syncDiscoveryLocks() ?? Future<void>.value());
services.messages.subscribe(channel: accountActionChannel).listen((_) => unawaited(mcpServer?.syncDiscoveryLocks() ?? Future<void>.value()));
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
// shows socket-side logs alongside kernel/extension ones.
ipcLog.addSink(services.logRing.add);
@@ -488,6 +602,7 @@ Future<void> main() async {
..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension())
..register(ClaudeControlExtension())
..register(ToolsSettingsExtension())
..register(CliInstallExtension());
await services.extensions.activateAll();
@@ -506,6 +621,40 @@ Future<void> main() async {
runApp(ClideApp(services: services));
}
/// Adapts the foundation-bound [AccountRegistry] + bootstrap probe to the
/// Flutter-free [AccountStore] port the `claude account` verbs use (T-480).
class _AccountStoreAdapter implements AccountStore {
_AccountStoreAdapter(this._reg, this._home);
final AccountRegistry _reg;
final String _home;
@override
List<({String name, String dir})> get accounts => [for (final a in _reg.accounts) (name: a.name, dir: a.dir)];
@override
String? boundAccountName(String cwd) => _reg.boundName(cwd);
@override
Set<String> boundAccountNames() => _reg.boundAccountNames();
@override
String defaultDirFor(String name) => '$_home/.claude-$name';
@override
List<String> detectedDirs() {
final registered = {for (final a in _reg.accounts) a.dir};
return [
for (final d in probeExistingAccountDirs(_home))
if (!registered.contains(d.dir)) d.dir,
];
}
@override
Future<void> add(String name, String dir) => _reg.registerAccount(name, dir);
@override
Future<void> remove(String name) => _reg.removeAccount(name);
@override
Future<void> bind(String cwd, String name) => _reg.bindWorkspace(cwd, name);
@override
Future<void> unbind(String cwd) => _reg.unbindWorkspace(cwd);
}
class _BusEventSink implements DaemonEventSink {
_BusEventSink(this._bus);
final DaemonBus _bus;
@@ -537,39 +686,13 @@ Future<Directory> _resolveAppDir() async {
Future<List<ThemeDefinition>> _loadBundledThemes() async {
const loader = ThemeLoader();
const paths = [
'lib/kernel/src/theme/themes/clide.yaml',
'lib/kernel/src/theme/themes/midnight.yaml',
'lib/kernel/src/theme/themes/paper.yaml',
'lib/kernel/src/theme/themes/terminal.yaml',
'lib/kernel/src/theme/themes/clide-hc.yaml',
'lib/kernel/src/theme/themes/midnight-hc.yaml',
'lib/kernel/src/theme/themes/paper-hc.yaml',
'lib/kernel/src/theme/themes/terminal-hc.yaml',
'lib/kernel/src/theme/themes/catppuccin-mocha.yaml',
'lib/kernel/src/theme/themes/catppuccin-mocha-hc.yaml',
];
final out = <ThemeDefinition>[];
for (final p in paths) {
for (final p in kBundledThemePaths) {
out.add(await loader.fromAsset(rootBundle, p));
}
return out;
}
/// Every Tier-0 extension that ships an i18n catalog. Extensions
/// registered but not active (the 17 stubs) don't preload — their
/// catalogs load lazily on activate in later tiers.
const List<String> _tier0Namespaces = [
// Framework chrome that lives outside any extension (lib/widgets, lib/kernel,
// shared reader chrome) resolves under the 'core' namespace (T-469).
'core',
'builtin.default-layout',
'builtin.welcome',
'builtin.ipc-status',
'builtin.theme-picker',
'builtin.terminal',
'builtin.files',
'builtin.claude',
'builtin.editor',
];
// The Tier-0 i18n namespace list now lives in
// lib/kernel/src/i18n/tier0_namespaces.dart as `kTier0Namespaces`, the single
// source of truth shared with the i18n coverage gate (T-371).
+15 -1
View File
@@ -36,7 +36,21 @@ ArgvParseResult unwrapArgvRequest(IpcRequest outer) {
),
);
}
return parseArgv(raw.cast<String>(), requestId: outer.id);
final result = parseArgv(raw.cast<String>(), requestId: outer.id);
// A piped `--stdin` payload (T-315): the C client slurps stdin and ships it
// alongside the argv. Fold it into the inner request as a `stdin` flag so it
// surfaces as a named arg (undeclared keys pass the schema untouched) — the
// handler reads it as the structured payload, the piped peer of `--file`.
final stdin = outer.args['stdin'];
if (result is ArgvParsed && stdin is String) {
final req = result.request;
final args = Map<String, Object?>.from(req.args);
final flags = Map<String, Object?>.from((args['flags'] as Map?) ?? const {});
flags['stdin'] = stdin;
args['flags'] = flags;
return ArgvParsed(IpcRequest(id: req.id, cmd: req.cmd, args: args));
}
return result;
}
/// Wire the `_argv` sentinel handler onto [dispatcher]. The handler
+1 -1
View File
@@ -27,7 +27,7 @@ import 'package:clide/src/ipc/schema_v1.dart';
/// split. Match the IDs the dispatcher exposes directly. `tail` and
/// `events` are handled by the IPC server itself (streaming / cursor-pull
/// event reads, T-129 / T-223) rather than the dispatcher.
const Set<String> _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities'};
const Set<String> _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities', 'instance', 'draw'};
/// Sealed result of translating argv. Caller (the IPC server, or the
/// C client wrapper in T-126) handles either branch.
+166
View File
@@ -0,0 +1,166 @@
/// Registers the `claude account …` verbs — the CLI half of the per-repo
/// Claude account feature (T-480, epic T-476; D-6 CLI parity). UI tickets
/// (T-481/T-482) call these verbs only; nothing else writes the registry.
///
/// `clide claude account add <name> [--dir <path>]`
/// `clide claude account list`
/// `clide claude account login <name>`
/// `clide claude account set <name>`
/// `clide claude account unset`
/// `clide claude account remove <name> [--purge]`
///
/// The argv grammar splits the first two tokens as `subsystem.verb`, so the
/// command id is `claude.account` and the sub-verb arrives as the first
/// positional. Registry reads/writes go through an injected [AccountStore] port
/// and side-effects (respawn on set/unset, the login terminal pane, --purge rm)
/// are published on [accountActionChannel] for the Claude extension to perform —
/// keeping this handler Flutter-free so it runs under `dart test`.
library;
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import 'dispatcher.dart';
import 'ui_command.dart' show MessagePublisher;
/// The MessageBus channel account actions publish on; the Claude extension
/// subscribes to the same literal to perform the side-effects.
const accountActionChannel = 'claude.account';
/// Flutter-free port over the (foundation-bound) AccountRegistry, injected so
/// this command runs under `dart test`. main.dart adapts the real registry +
/// bootstrap probe to it.
abstract class AccountStore {
/// Registered accounts as `(name, dir)` records, in stored order.
List<({String name, String dir})> get accounts;
/// The account name bound to [cwd], or null.
String? boundAccountName(String cwd);
/// Every account name some workspace is bound to (for the in-use check).
Set<String> boundAccountNames();
/// Default config dir for a new account [name] (e.g. `~/.claude-<name>`).
String defaultDirFor(String name);
/// Unregistered `~/.claude-*` dirs the bootstrap probe found (adoption hints).
List<String> detectedDirs();
Future<void> add(String name, String dir);
Future<void> remove(String name);
Future<void> bind(String cwd, String name);
Future<void> unbind(String cwd);
}
/// Register `claude.account`. [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.
void registerClaudeAccountCommands(
DaemonDispatcher d,
AccountStore? Function() store, {
MessagePublisher? Function()? publisher,
String? Function()? workspaceCwd,
}) {
d.register(
'claude.account',
(req) async => _dispatch(req, store(), publisher?.call(), workspaceCwd?.call()),
schema: const CommandSchema(
positional: ['action', 'name'],
args: {
'action': ArgSpec(required: true, rejectLeadingDash: true),
'name': ArgSpec(rejectLeadingDash: true),
'dir': ArgSpec(),
'purge': ArgSpec(type: ArgType.boolean),
},
),
);
}
({String name, String dir})? _byName(AccountStore store, String name) {
for (final a in store.accounts) {
if (a.name == name) return a;
}
return null;
}
Future<IpcResponse> _dispatch(IpcRequest req, AccountStore? store, MessagePublisher? publish, String? cwd) async {
if (store == null) return _err(req.id, 'account registry unavailable in this context');
final action = (req.args['action'] as String?)?.trim();
final name = (req.args['name'] as String?)?.trim();
final dir = (req.args['dir'] as String?)?.trim();
final purge = req.args['purge'] == true;
switch (action) {
case 'list':
return _ok(req.id, {
'accounts': [
for (final a in store.accounts) {'name': a.name, 'dir': a.dir},
],
'boundAccount': cwd == null ? null : store.boundAccountName(cwd),
'detected': store.detectedDirs(),
});
case 'add':
if (name == null || name.isEmpty) return _err(req.id, 'account add requires a <name>');
final target = (dir == null || dir.isEmpty) ? store.defaultDirFor(name) : dir;
final existing = _byName(store, name);
if (existing != null) {
// Idempotent: same dir is a no-op; a conflicting --dir is a userError.
if (existing.dir == target) return _ok(req.id, {'name': name, 'dir': target, 'created': false});
return _err(req.id, 'account "$name" already exists at ${existing.dir}', hint: 'remove it first, or omit --dir to keep it');
}
await store.add(name, target);
return _ok(req.id, {'name': name, 'dir': target, 'created': true});
case 'remove':
if (name == null || name.isEmpty) return _err(req.id, 'account remove requires a <name>');
final removing = _byName(store, name);
if (removing == null) return _err(req.id, 'no such account: "$name"');
if (store.boundAccountNames().contains(name)) {
return _err(req.id, 'account "$name" is bound to a workspace', hint: 'clide claude account unset (in that workspace) first');
}
await store.remove(name);
// The dir delete is IO the extension owns (this handler is Flutter-free).
// Carry the dir in the payload — the account is gone from the registry now.
if (purge) publish?.call('cli', accountActionChannel, {'action': 'purge', 'name': name, 'dir': removing.dir});
return _ok(req.id, {'removed': name, 'purge': purge});
case 'set':
if (name == null || name.isEmpty) return _err(req.id, 'account set requires a <name>');
if (cwd == null) return _err(req.id, 'no workspace to bind');
if (_byName(store, name) == null) return _err(req.id, 'no such account: "$name"', hint: 'clide claude account add $name');
await store.bind(cwd, name);
// The extension respawns the active pane(s) on the new account.
publish?.call('cli', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
return _ok(req.id, {'bound': name, 'cwd': cwd});
case 'unset':
if (cwd == null) return _err(req.id, 'no workspace to unbind');
final prev = store.boundAccountName(cwd);
await store.unbind(cwd);
publish?.call('cli', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': prev});
return _ok(req.id, {'unbound': prev, 'cwd': cwd});
case 'login':
if (name == null || name.isEmpty) return _err(req.id, 'account login requires a <name>');
final acct = _byName(store, name);
if (acct == null) return _err(req.id, 'no such account: "$name"', hint: 'clide claude account add $name');
// The extension spawns `CLAUDE_CONFIG_DIR=<dir> claude login` in a pane.
publish?.call('cli', accountActionChannel, {'action': 'login', 'name': name, 'dir': acct.dir});
return _ok(req.id, {'login': name, 'dir': acct.dir});
default:
return _err(
req.id,
'unknown account action: ${action == null || action.isEmpty ? '(none)' : action}',
hint: 'use: add | list | login | set | unset | remove',
);
}
}
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),
);
+109
View File
@@ -0,0 +1,109 @@
/// Registers `draw` — drive a drawing card into the Claude conversation from
/// the CLI (T-318, D-91 / D-103, D-6 parity).
///
/// clide draw --file card.json
///
/// The card is clide-owned rendering (the SVG engine + the [DrawingCard]
/// widget); this is its CLI counterpart. Mirroring `image.show`, the handler is
/// decoupled from the live UI: it reads + parses the JSON document (via an
/// injected [DrawingFileReader]), lowers it to an SVG string through the
/// template [DrawingRegistry], then publishes a `draw` message on the kernel
/// MessageBus. A consumer in the Claude extension builds the SvgDocument and
/// injects the card into the primary session. Flutter-free so it runs under
/// `dart test`.
library;
import 'dart:convert';
import '../draw/draw_dispatch.dart';
import '../draw/draw_doc.dart';
export '../draw/draw_dispatch.dart' show DrawErr, DrawOk, DrawResult, DrawingFileReader, DrawingRegistry, DrawingTemplateHandler;
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import 'dispatcher.dart';
import 'ui_command.dart' show MessagePublisher;
/// The MessageBus channel `draw` publishes on; the Claude extension subscribes
/// to the same literal to inject the card.
const drawShowChannel = 'draw';
void registerDrawCommands(
DaemonDispatcher d,
MessagePublisher? Function() publisher, {
required DrawingRegistry registry,
required DrawingFileReader readFile,
}) {
d.register(
'draw',
(req) async => _draw(req, publisher, registry, readFile),
schema: const CommandSchema(args: {'file': ArgSpec(required: true, rejectLeadingDash: true)}),
);
}
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
id: id,
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
);
Future<IpcResponse> _draw(IpcRequest req, MessagePublisher? Function() publisherSource, DrawingRegistry registry, DrawingFileReader readFile) async {
final file = req.args['file'] as String?;
if (file == null || file.trim().isEmpty) {
return _userErr(req.id, 'a drawing-card document is required (e.g. `draw --file card.json`)');
}
final raw = await readFile(file);
if (raw == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such file: $file',
hint: 'path is resolved relative to the workspace root',
),
);
}
// Type inference from the extension (T-494): a `.d2`/`.svg` file is the raw
// source, not a JSON envelope — wrap it in the matching doc. Everything else
// is a drawing-card JSON document.
final lower = file.toLowerCase();
final DrawingCardDoc? doc;
if (lower.endsWith('.d2')) {
doc = DrawingCardDoc(template: 'd2', fields: {'template': 'd2', 'source': raw});
} else if (lower.endsWith('.svg')) {
doc = DrawingCardDoc(svg: raw);
} else {
final Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in $file: ${e.message}');
}
doc = parseDrawingCardDoc(decoded);
if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object');
}
final result = await resolveDrawingSvg(doc, registry, readFile: readFile);
if (result is DrawErr) return _userErr(req.id, result.message);
final svg = (result as DrawOk).svg;
final publish = publisherSource();
if (publish == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
);
}
publish('cli', drawShowChannel, {
'svg': svg,
if (doc.label != null) 'label': doc.label,
if (doc.description != null) 'description': doc.description,
// d2 cards carry their source so the card can offer a "view source" peek.
if (doc.template == 'd2' && doc.fields['source'] is String) 'source': doc.fields['source'],
});
return IpcResponse.ok(id: req.id, data: {'shown': true, if (doc.template != null) 'template': doc.template});
}
+136
View File
@@ -0,0 +1,136 @@
/// Registers `icon.show` — drive a Phosphor glyph card into the Claude
/// conversation from the CLI (T-313, D-6 parity).
///
/// clide icon show gear folder gauge
/// clide icon show --file icons.json # entries with label/description/color
///
/// Mirrors `image.show` end to end: a Flutter-free handler validates + resolves
/// each glyph (via an injected [IconResolver], so dart-test needs no font),
/// validates any colors (reusing [parseSvgColor] — hex or CSS name), then
/// publishes on the `icon` MessageBus channel. The Claude extension injects the
/// matching card. Honest userError on an unknown glyph, a malformed/missing
/// `--file`, or an unparseable color; toolError when there is no live UI.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'dart:convert';
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../svg/svg_color.dart' show parseSvgColor;
import 'dispatcher.dart';
import 'ui_command.dart' show MessagePublisher;
/// Resolves a Phosphor glyph name to its codepoint, or null if unknown. Injected
/// (wired to `kPhosphorGlyphs` in main.dart) so this file stays Flutter-free.
typedef IconResolver = int? Function(String name);
/// Reads a metadata JSON file's contents, or null if unreadable. Injected for
/// testability.
typedef IconFileReader = Future<String?> Function(String path);
/// The MessageBus channel `icon.show` publishes on; the Claude extension
/// subscribes to the same literal to inject the card.
const iconShowChannel = 'icon';
void registerIconCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {required IconResolver resolve, IconFileReader? readFile}) {
d.register(
'icon.show',
(req) async => _show(req, publisher, resolve, readFile),
schema: const CommandSchema(
positional: ['icons'],
args: {
'icons': ArgSpec(type: ArgType.stringList, rejectLeadingDash: true),
'file': ArgSpec(rejectLeadingDash: true),
'color': ArgSpec(),
},
),
);
}
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
id: id,
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
);
Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, IconResolver resolve, IconFileReader? readFile) async {
final defaultColor = _str(req.args['color']);
if (defaultColor != null && parseSvgColor(defaultColor) == null) {
return _userErr(req.id, 'invalid color: $defaultColor', hint: '#rrggbb, #rrggbbaa, or a CSS color name');
}
final entries = <Map<String, Object?>>[];
// The entry payload comes from a piped --stdin (T-315) or a --file; --stdin
// wins. Either is a JSON array of {icon,label,description,color}.
final stdin = _str(req.args['stdin']);
final file = _str(req.args['file']);
String? payload = stdin;
if (payload == null && file != null) {
payload = readFile == null ? null : await readFile(file);
if (payload == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such file: $file',
hint: 'path is resolved relative to the workspace root',
),
);
}
}
if (payload != null) {
Object? decoded;
try {
decoded = jsonDecode(payload);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in the icon payload: ${e.message}');
}
if (decoded is! List) return _userErr(req.id, 'icon metadata must be a JSON array of entries');
for (final item in decoded) {
if (item is! Map) return _userErr(req.id, 'each icon entry must be a JSON object');
final token = _str(item['icon']);
if (token == null) return _userErr(req.id, 'each entry needs an "icon" name or 0x codepoint');
final cp = _resolveIcon(token, resolve);
if (cp == null) return _userErr(req.id, 'unknown icon: $token', hint: 'a kebab-case Phosphor name (e.g. gear) or a 0xNNNN codepoint');
final color = _str(item['color']);
if (color != null && parseSvgColor(color) == null) return _userErr(req.id, 'invalid color: $color', hint: '#rrggbb, #rrggbbaa, or a CSS color name');
entries.add({'codepoint': cp, 'name': token, 'label': ?_str(item['label']), 'description': ?_str(item['description']), 'color': ?color});
}
} else {
final icons = (req.args['icons'] as List?)?.whereType<String>() ?? const <String>[];
for (final token in icons) {
final cp = _resolveIcon(token, resolve);
if (cp == null) return _userErr(req.id, 'unknown icon: $token', hint: 'a kebab-case Phosphor name (e.g. gear) or a 0xNNNN codepoint');
entries.add({'codepoint': cp, 'name': token});
}
}
if (entries.isEmpty) {
return _userErr(req.id, 'at least one icon is required (e.g. `icon show gear folder` or `icon show --file icons.json`)');
}
final publish = publisherSource();
if (publish == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
);
}
publish('cli', iconShowChannel, {'entries': entries, 'color': ?defaultColor});
return IpcResponse.ok(id: req.id, data: {'shown': true, 'count': entries.length});
}
/// Resolve an icon [token] — a `0xNNNN` codepoint or a glyph name — to a
/// codepoint, or null if it doesn't parse / resolve.
int? _resolveIcon(String token, IconResolver resolve) {
final t = token.trim();
if (t.startsWith('0x') || t.startsWith('0X')) return int.tryParse(t.substring(2), radix: 16);
return resolve(t);
}
/// Trimmed non-empty string, or null — for tolerant arg/JSON reads.
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
+60 -9
View File
@@ -14,6 +14,8 @@
/// `dart test`.
library;
import 'dart:convert';
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
@@ -30,20 +32,26 @@ const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
/// the real filesystem; main.dart wires it to the workspace root + `File`.
typedef ImagePathResolver = String? Function(String path);
/// Reads a metadata JSON file's contents, or null if unreadable. Injected so
/// this file stays Flutter-free and unit-testable (T-316).
typedef ImageFileReader = Future<String?> Function(String path);
/// The MessageBus channel `image.show` publishes on; the Claude extension
/// subscribes to the same literal to inject the card. Kept here next to the
/// publisher so both ends point at one name.
const imageShowChannel = 'image';
void registerImageCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {ImagePathResolver? resolve}) {
void registerImageCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {ImagePathResolver? resolve, ImageFileReader? readFile}) {
d.register(
'image.show',
(req) async => _show(req, publisher, resolve),
(req) async => _show(req, publisher, resolve, readFile),
schema: const CommandSchema(
positional: ['path'],
args: {
'path': ArgSpec(required: true, rejectLeadingDash: true),
// Not required — the path may instead come from a --file payload (T-316).
'path': ArgSpec(rejectLeadingDash: true),
'caption': ArgSpec(),
'file': ArgSpec(rejectLeadingDash: true),
'fullscreen': ArgSpec(type: ArgType.boolean),
},
),
@@ -55,10 +63,47 @@ IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.e
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
);
Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve) async {
final path = req.args['path'] as String?;
Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve, ImageFileReader? readFile) async {
String? path = req.args['path'] as String?;
String? label, description;
String? caption = req.args['caption'] as String?;
// An annotation payload {path,label,description,caption} from a piped --stdin
// (T-315) or a --file (T-316); --stdin wins. Additive — the bare
// `image show <path> [--caption]` form is unchanged.
final stdin = _str(req.args['stdin']);
final file = _str(req.args['file']);
String? payload = stdin;
if (payload == null && file != null) {
payload = readFile == null ? null : await readFile(file);
if (payload == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such file: $file',
hint: 'path is resolved relative to the workspace root',
),
);
}
}
if (payload != null) {
Object? decoded;
try {
decoded = jsonDecode(payload);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in the image payload: ${e.message}');
}
if (decoded is! Map) return _userErr(req.id, 'image metadata must be a JSON object');
path = _str(decoded['path']) ?? path;
label = _str(decoded['label']);
description = _str(decoded['description']);
caption = _str(decoded['caption']) ?? caption;
}
if (path == null || path.trim().isEmpty) {
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)');
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png` or `image show --file meta.json`)');
}
final ext = _extensionOf(path);
@@ -86,8 +131,6 @@ Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisher
resolved = abs;
}
final caption = req.args['caption'] as String?;
final publish = publisherSource();
if (publish == null) {
// No live UI bus — headless / CLI-only context. Honest failure, not a hang.
@@ -100,11 +143,19 @@ Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisher
publish('cli', imageShowChannel, {
'path': resolved,
if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(),
'label': ?label,
'description': ?description,
if (fullscreen) 'fullscreen': true,
});
return IpcResponse.ok(id: req.id, data: {'path': resolved, 'caption': ?caption, 'fullscreen': fullscreen, 'shown': true});
return IpcResponse.ok(
id: req.id,
data: {'path': resolved, 'caption': ?caption, 'label': ?label, 'description': ?description, 'fullscreen': fullscreen, 'shown': true},
);
}
/// Trimmed non-empty string, or null — for tolerant JSON field reads.
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
/// Lower-cased extension (without the dot) of [path], or '' if none.
String _extensionOf(String path) {
final slash = path.lastIndexOf('/');
+16
View File
@@ -0,0 +1,16 @@
/// Registers the `instance` command — this running clide's identity in one
/// round-trip (T-247): version, pid, workspace root, and socket path. It's the
/// per-instance metadata the `clide instances` CLI verb aggregates (by probing
/// every live socket in the runtime dir), and it lets a human or agent confirm
/// *which* instance a given socket belongs to.
///
/// Thin + Flutter-free: the caller (main.dart) passes the values it already
/// holds at dispatcher-build time, so this stays trivially testable.
library;
import '../ipc/envelope.dart';
import 'dispatcher.dart';
void registerInstanceCommand(DaemonDispatcher d, {required String version, required int pid, required String workspace, required String socketPath}) {
d.register('instance', (req) async => IpcResponse.ok(id: req.id, data: {'version': version, 'pid': pid, 'workspace': workspace, 'socketPath': socketPath}));
}
+130
View File
@@ -0,0 +1,130 @@
/// `clide project new <name> [--dir <parent>]` — create a new clide project
/// (T-487, story T-486). clide treats a git repo as the workspace, so a new
/// project is: a fresh directory, `git init`, and a minimal scaffold. The git
/// binary lives behind the toolchain in main.dart, so it's injected here as
/// [ProjectGitInit], keeping this handler Flutter-free (runs under `dart test`).
///
/// This verb creates only — opening the new workspace and the account roadblock
/// (T-488) are the UI flow's job; the CLI returns the created path.
library;
import 'dart:io';
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import 'dispatcher.dart';
/// MessageBus channel announcing a freshly-created project (path in `dir`). The
/// welcome new-project dialog publishes it after create + open; the Claude
/// extension consumes it to show the per-repo account roadblock (T-488) — only
/// NEW projects prompt, existing opens never do. Lives here in the neutral
/// daemon layer so both builtins name one literal without coupling to each other.
const projectCreatedChannel = 'project.created';
/// Runs `git init` in [dir]. Injected so this stays Flutter-free + testable;
/// main.dart wires it to the real toolchain.
typedef ProjectGitInit = Future<void> Function(String dir);
/// Outcome of a [createNewProject] attempt — the created path, or a clear error.
class NewProjectResult {
const NewProjectResult.ok(String this.path) : error = null;
const NewProjectResult.err(String this.error) : path = null;
final String? path;
final String? error;
bool get ok => error == null;
}
/// Validate a project name: a single folder segment, not a path or a dot-name.
String? validateProjectName(String name) {
final n = name.trim();
if (n.isEmpty) return 'project name is required';
if (n.contains('/') || n.contains(r'\')) return 'name must be a single folder, not a path';
if (n == '.' || n == '..' || n.startsWith('.')) return 'invalid project name: "$name"';
return null;
}
/// Create `<parent>/<name>/`, `git init` it (via [gitInit]), and write a minimal
/// scaffold. Never overwrites an existing entry. Returns the created path or a
/// clear, user-facing error.
Future<NewProjectResult> createNewProject({required String parent, required String name, required ProjectGitInit gitInit}) async {
final nameErr = validateProjectName(name);
if (nameErr != null) return NewProjectResult.err(nameErr);
final trimmedParent = _stripTrailingSep(parent.trim());
if (trimmedParent.isEmpty) return NewProjectResult.err('a parent directory is required');
if (!Directory(trimmedParent).existsSync()) return NewProjectResult.err('parent directory does not exist: $trimmedParent');
final target = '$trimmedParent/${name.trim()}';
if (Directory(target).existsSync() || File(target).existsSync()) {
return NewProjectResult.err('already exists: $target');
}
Directory(target).createSync(recursive: true);
await gitInit(target);
_writeScaffold(target, name.trim());
return NewProjectResult.ok(target);
}
/// `git init` an EXISTING folder that isn't a repo yet (T-489) — the "initialize
/// this folder as a clide project" path. Unlike [createNewProject] it never
/// creates the dir and never clobbers existing files; the scaffold is only
/// written where absent.
Future<NewProjectResult> initExistingProject({required String path, required ProjectGitInit gitInit}) async {
final trimmed = _stripTrailingSep(path.trim());
if (trimmed.isEmpty) return NewProjectResult.err('a directory is required');
if (!Directory(trimmed).existsSync()) return NewProjectResult.err('directory does not exist: $trimmed');
await gitInit(trimmed);
_writeScaffold(trimmed, trimmed.split('/').where((s) => s.isNotEmpty).lastOrNull ?? 'project');
return NewProjectResult.ok(trimmed);
}
void _writeScaffold(String dir, String name) {
// Minimal + non-prescriptive: keep clide's own state out of git, and orient
// 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');
final claudeMd = File('$dir/CLAUDE.md');
if (!claudeMd.existsSync()) claudeMd.writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n');
}
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;
}
/// Register `project.new` + `project.init`. [gitInit] runs git init;
/// [defaultParent] supplies the new-project parent when `--dir` is omitted (the
/// current workspace's parent); [defaultInitPath] supplies the init target when
/// `--dir` is omitted (the current workspace, so `clide project init` inits the
/// folder you're in).
void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent, String? Function()? defaultInitPath}) {
d.register('project.new', (req) async {
final name = (req.args['name'] as String?)?.trim();
if (name == null || name.isEmpty) return _err(req.id, 'project new requires a <name>');
final parent = (req.args['dir'] as String?)?.trim() ?? defaultParent?.call();
if (parent == null || parent.isEmpty) {
return _err(req.id, 'no parent directory to create in', hint: 'pass --dir <parent>');
}
final result = await createNewProject(parent: parent, name: name, gitInit: gitInit);
if (!result.ok) return _err(req.id, result.error!);
return IpcResponse.ok(id: req.id, data: {'path': result.path, 'name': name});
}, schema: const CommandSchema(positional: ['name'], args: {'name': ArgSpec(required: true, rejectLeadingDash: true), 'dir': ArgSpec()}));
d.register('project.init', (req) async {
final path = (req.args['dir'] as String?)?.trim() ?? defaultInitPath?.call();
if (path == null || path.isEmpty) return _err(req.id, 'no directory to initialize', hint: 'pass --dir <path>');
final result = await initExistingProject(path: path, gitInit: gitInit);
if (!result.ok) return _err(req.id, result.error!);
return IpcResponse.ok(id: req.id, data: {'path': result.path});
}, schema: const CommandSchema(args: {'dir': ArgSpec()}));
}
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
id: id,
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
);
+57
View File
@@ -0,0 +1,57 @@
/// The `compare` drawing-card template (T-319 / D-91 / D-103).
///
/// A before/after (or N-up) comparison: the doc's `images` array — each
/// `{path, label, description}` — lowers to an SVG of side-by-side `<image>`
/// cells, every cell carrying the per-object `data-label` / `data-description`
/// (the T-318 caption overlay) and `data-lightbox` (tap-to-zoom, shared with the
/// image template). The same renderer (T-320) paints it; the painter aspect-fits
/// each image into its cell so differing shapes don't distort.
///
/// Paths are resolved to absolute up front via an injected resolver (like
/// image.show), so the card loader just reads the file — and an unresolvable
/// path is an honest [DrawErr], not a broken cell.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'draw_dispatch.dart';
/// Resolves a user-supplied image path to an absolute existing path, or null.
typedef ComparePathResolver = String? Function(String path);
const _cellW = 320, _cellH = 240, _gap = 20, _capH = 56;
/// Handler for `template: "compare"` — reads the doc's `images` array. Register
/// this in the [DrawingRegistry] with a path [resolvePath] (wired to the
/// workspace root in main.dart).
DrawingTemplateHandler compareTemplateHandler({required ComparePathResolver resolvePath}) {
return (doc) async {
final images = doc.fields['images'];
if (images is! List || images.isEmpty) {
return const DrawErr('the compare template needs a non-empty "images" array of {path,label,description}');
}
final cells = StringBuffer();
for (var i = 0; i < images.length; i++) {
final item = images[i];
if (item is! Map) return const DrawErr('each compare image must be an object with a "path"');
final path = _str(item['path']);
if (path == null) return const DrawErr('each compare image needs a "path"');
final abs = resolvePath(path);
if (abs == null) return DrawErr('no such image: $path');
final x = i * (_cellW + _gap);
final label = _str(item['label']);
final desc = _str(item['description']);
cells.write('<image href="${_esc(abs)}" x="$x" y="0" width="$_cellW" height="$_cellH"');
if (label != null) cells.write(' data-label="${_esc(label)}"');
if (desc != null) cells.write(' data-description="${_esc(desc)}"');
cells.write(' data-lightbox=""/>');
}
final n = images.length;
final totalW = n * _cellW + (n - 1) * _gap;
return DrawOk('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 $totalW ${_cellH + _capH}">$cells</svg>');
};
}
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
String _esc(String s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+80
View File
@@ -0,0 +1,80 @@
/// The `d2` drawing-card template (T-494 / D-91 / D-103).
///
/// A d2 diagram is just an SVG card with a compile step in front: the doc's
/// `source` (d2 diagram text) is compiled to SVG, then painted by the SAME
/// renderer the `svg` card uses (T-320). The compile shells out to the `d2`
/// binary — the supporter-tool pattern (peer of pql/git, D-3/D-5), resolved via
/// the D-104 path layer (T-495). No second core language, no vendored Go.
///
/// Honest failures (D-103): a missing source, an unresolved `d2`, or a compile
/// error each return a [DrawErr] with a user-facing message + hint, which the
/// command layer turns into an IpcError userError — never a throw.
///
/// Flutter-free: pure Dart (dart:io), runs under `dart test`. The process spawn
/// is injectable ([D2Compiler]) so the handler is tested without a real binary.
library;
import 'dart:convert';
import 'dart:io';
import '../env/supporter_binaries.dart';
import 'draw_dispatch.dart';
/// Compiles d2 [source] to an SVG [DrawResult]. Injected into
/// [d2TemplateHandler] so it is testable; the default is [d2CompileViaBinary].
typedef D2Compiler = Future<DrawResult> Function(String source);
/// Handler for `template: "d2"` — reads the doc's `source` field (the diagram
/// text) and compiles it. Register this in the [DrawingRegistry].
DrawingTemplateHandler d2TemplateHandler({D2Compiler compile = d2CompileViaBinary}) {
return (doc) async {
final source = doc.fields['source'];
if (source is! String || source.trim().isEmpty) {
return const DrawErr('the d2 template needs a non-empty "source" field (the d2 diagram text)');
}
return compile(source);
};
}
/// One run of the d2 binary: its exit code, stdout (SVG) and stderr.
typedef D2RunResult = ({int code, String out, String err});
/// Runs the d2 [exe] over [source]. Injected so [d2CompileViaBinary] is tested
/// without a real binary; the default is [_spawnD2].
typedef D2Run = Future<D2RunResult> Function(String exe, String source);
/// Resolve the `d2` binary (D-104) and compile [source] through it. Failure keys
/// off the exit code — d2 logs `success:` to stderr on a clean compile, so a
/// non-empty stderr is not itself an error. [resolveD2] and [run] are injectable
/// for testing; the defaults use [activeSupporterBinaries] and a real spawn.
Future<DrawResult> d2CompileViaBinary(String source, {String? Function()? resolveD2, D2Run run = _spawnD2}) async {
final d2 = (resolveD2 ?? _defaultResolveD2)();
if (d2 == null) {
return const DrawErr('d2 not found — install it from https://d2lang.com, or set its path in Settings → Tools');
}
final D2RunResult r;
try {
r = await run(d2, source);
} catch (e) {
return DrawErr('could not run d2 ($d2): $e');
}
if (r.code != 0) {
return DrawErr('d2 compile failed: ${r.err.trim().isEmpty ? 'exit ${r.code}' : r.err.trim()}');
}
if (r.out.trim().isEmpty) return const DrawErr('d2 produced no SVG');
return DrawOk(r.out);
}
String? _defaultResolveD2() => (activeSupporterBinaries ?? SupporterBinaries()).resolve('d2');
/// `d2 - -` — read source on stdin, write SVG to stdout. Drains stdout/stderr
/// concurrently with the stdin write to avoid a pipe deadlock on a big diagram.
Future<D2RunResult> _spawnD2(String exe, String source) async {
final proc = await Process.start(exe, const ['-', '-']);
final outF = proc.stdout.transform(utf8.decoder).join();
final errF = proc.stderr.transform(utf8.decoder).join();
proc.stdin.write(source);
await proc.stdin.close();
final code = await proc.exitCode;
return (code: code, out: await outF, err: await errF);
}
+71
View File
@@ -0,0 +1,71 @@
/// Drawing-card template dispatch (T-318 / D-103).
///
/// Lowers a [DrawingCardDoc] to an SVG string — the substrate the renderer
/// paints (D-103). In primitive mode the SVG is the doc's inline `svg` or the
/// contents of its `svgPath`; in template mode a registered handler (`d2`,
/// `icon`, `compare`, `image` — the child tickets) produces the SVG from the
/// doc's fields. The handlers and the file reader are injected, so this stays
/// headless- and `dart test`-friendly (no ambient filesystem, mirroring how
/// image.show injects its path resolver).
///
/// Honest result: every failure (no source, unknown template, unreadable path,
/// empty handler output) returns a [DrawErr] with a message rather than
/// throwing — the command layer turns it into an IpcError userError.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'draw_doc.dart';
/// Lowers a template-mode doc to SVG, as a [DrawResult] — [DrawOk] with the SVG
/// or [DrawErr] carrying an honest, user-facing message (e.g. a compile failure
/// or an unresolved tool, with a hint).
typedef DrawingTemplateHandler = Future<DrawResult> Function(DrawingCardDoc doc);
/// Reads a file's contents, or `null` if unreadable. Injected for testability.
typedef DrawingFileReader = Future<String?> Function(String path);
/// Registry of template handlers, keyed by `template` name.
class DrawingRegistry {
final Map<String, DrawingTemplateHandler> _handlers = {};
/// Register (or replace) the handler for [template].
void register(String template, DrawingTemplateHandler handler) => _handlers[template] = handler;
DrawingTemplateHandler? handlerFor(String template) => _handlers[template];
bool get isEmpty => _handlers.isEmpty;
}
/// Outcome of lowering a doc to SVG.
sealed class DrawResult {
const DrawResult();
}
class DrawOk extends DrawResult {
const DrawOk(this.svg);
final String svg;
}
class DrawErr extends DrawResult {
const DrawErr(this.message);
final String message;
}
/// Lower [doc] to an SVG string. Primitive docs use their inline `svg` or read
/// `svgPath` via [readFile]; template docs use the matching handler in
/// [registry].
Future<DrawResult> resolveDrawingSvg(DrawingCardDoc doc, DrawingRegistry registry, {required DrawingFileReader readFile}) async {
if (doc.isPrimitive) {
if (doc.svg != null) return DrawOk(doc.svg!);
if (doc.svgPath != null) {
final contents = await readFile(doc.svgPath!);
return contents == null ? DrawErr('cannot read ${doc.svgPath}') : DrawOk(contents);
}
return const DrawErr('drawing card has no svg, svgPath, or template');
}
final handler = registry.handlerFor(doc.template!);
if (handler == null) return DrawErr('unknown drawing template: ${doc.template}');
return handler(doc);
}
+56
View File
@@ -0,0 +1,56 @@
/// The drawing-card document model + envelope parser (T-318 / D-91 / D-103).
///
/// A `clide draw` payload is a JSON document. In PRIMITIVE mode it carries raw
/// SVG (`svg` inline or `svgPath`); in TEMPLATE mode it names a `template`
/// (`d2`, `icon`, `compare`, `image`, …) whose fields a registered handler
/// lowers to SVG. Either way an optional card-level `label`/`description`
/// renders as a caption beneath the drawing (D-103: SVG is the substrate; a thin
/// Flutter overlay carries the chrome).
///
/// This is just the typed envelope + a tolerant parser — template lowering and
/// painting live elsewhere. Never throws; a non-object payload yields `null`.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
/// A parsed drawing-card document.
class DrawingCardDoc {
const DrawingCardDoc({this.label, this.description, this.template, this.svg, this.svgPath, this.fields = const {}});
/// Card-level caption, shown beneath the drawing when present.
final String? label, description;
/// Template name (`d2`, `icon`, …). `null` or `svg` ⇒ primitive mode.
final String? template;
/// Primitive-mode SVG: inline source, or a path to an `.svg`.
final String? svg, svgPath;
/// The full document map, so a template handler can read its own fields
/// (`source`, `items`, `path`, …).
final Map<String, Object?> fields;
/// True when the card is raw SVG rather than a named template.
bool get isPrimitive => template == null || template == 'svg';
}
/// Parse a decoded-JSON drawing-card document (a `Map`). Returns `null` only
/// when [json] isn't a JSON object; never throws.
DrawingCardDoc? parseDrawingCardDoc(Object? json) {
if (json is! Map) return null;
final card = json['card'];
final cardMap = card is Map ? card : const {};
return DrawingCardDoc(
label: _str(cardMap['label']) ?? _str(json['label']),
description: _str(cardMap['description']) ?? _str(json['description']),
template: _str(json['template']),
svg: _str(json['svg']),
svgPath: _str(json['svgPath']),
fields: {
for (final e in json.entries)
if (e.key is String) e.key as String: e.value,
},
);
}
String? _str(Object? v) => v is String && v.isNotEmpty ? v : null;
+87
View File
@@ -0,0 +1,87 @@
/// The `graph` drawing-card template (T-321 / D-91 / D-103).
///
/// Lowers a `{nodes:[{id,label}], edges:[{from,to}]}` payload to an SVG the
/// shared renderer (T-320) paints: a deterministic circular layout — nodes on a
/// ring as labelled `<circle>`s, edges as `<line>`s between them. Display-only
/// per D-78; the card-level label/description (T-318) renders beneath. Distinct
/// from the interactive force-directed graph PANE (T-323) — this is a static
/// graph dropped into the conversation.
///
/// Self-contained like a d2 diagram: it carries its own light backdrop + content
/// colors (the graph is content, not clide chrome — its own palette). Honest
/// [DrawErr] on an empty node set, a duplicate id, or an edge to an unknown node.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'dart:math' as math;
import 'draw_dispatch.dart';
const _nodeR = 9.0; // node circle radius
const _ringR = 140.0; // layout ring radius
const _pad = 52.0; // room for labels around the ring
DrawingTemplateHandler graphTemplateHandler() {
return (doc) async {
final nodesRaw = doc.fields['nodes'];
if (nodesRaw is! List || nodesRaw.isEmpty) {
return const DrawErr('the graph template needs a non-empty "nodes" array of {id,label}');
}
final ids = <String>[];
final labels = <String>[];
for (final node in nodesRaw) {
if (node is! Map) return const DrawErr('each graph node must be a JSON object {id,label}');
final id = _str(node['id']);
if (id == null) return const DrawErr('each graph node needs an "id"');
if (ids.contains(id)) return DrawErr('duplicate node id: $id');
ids.add(id);
labels.add(_str(node['label']) ?? id);
}
final n = ids.length;
final cx = _ringR + _pad, cy = _ringR + _pad;
final px = List<double>.filled(n, cx), py = List<double>.filled(n, cy);
for (var i = 0; i < n && n > 1; i++) {
final a = -math.pi / 2 + 2 * math.pi * i / n; // start at top, clockwise
px[i] = cx + _ringR * math.cos(a);
py[i] = cy + _ringR * math.sin(a);
}
final index = {for (var i = 0; i < n; i++) ids[i]: i};
// Edges first so nodes paint on top of them.
final edges = StringBuffer();
final edgesRaw = doc.fields['edges'];
if (edgesRaw is List) {
for (final edge in edgesRaw) {
if (edge is! Map) return const DrawErr('each graph edge must be a JSON object {from,to}');
final from = _str(edge['from']), to = _str(edge['to']);
if (from == null || to == null) return const DrawErr('each graph edge needs a "from" and a "to"');
final fi = index[from], ti = index[to];
if (fi == null) return DrawErr('edge references unknown node: $from');
if (ti == null) return DrawErr('edge references unknown node: $to');
edges.write('<line x1="${_fmt(px[fi])}" y1="${_fmt(py[fi])}" x2="${_fmt(px[ti])}" y2="${_fmt(py[ti])}" stroke="#9aa4b2" stroke-width="1.5"/>');
}
}
final nodes = StringBuffer();
for (var i = 0; i < n; i++) {
nodes.write('<circle cx="${_fmt(px[i])}" cy="${_fmt(py[i])}" r="$_nodeR" fill="#4a90d9"/>');
nodes.write('<text x="${_fmt(px[i])}" y="${_fmt(py[i] - _nodeR - 5)}" text-anchor="middle" font-size="13" fill="#33373d">${_esc(labels[i])}</text>');
}
final size = 2 * (_ringR + _pad);
return DrawOk(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${_fmt(size)} ${_fmt(size)}">'
'<rect width="${_fmt(size)}" height="${_fmt(size)}" fill="#fafafa"/>'
'$edges$nodes</svg>',
);
};
}
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
String _fmt(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2);
String _esc(String s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+6 -1
View File
@@ -180,8 +180,13 @@ class EditorRegistry {
if (buf == null) return;
_pathToId.remove(buf.path);
if (_activeId == id) {
// Promote the next buffer, or clear to null when this was the last one.
// Emit active-changed in BOTH cases: a null id is the signal the editor
// split collapses on (T-459). Guarding the emit on `_activeId != null`
// suppressed exactly the last-buffer-closed event, leaving editorOpen
// stuck true and the top split orphaned over the primary pane.
_activeId = _buffers.values.isEmpty ? null : _buffers.values.first.id;
if (_activeId != null) _emitActive();
_emitActive();
}
_emit('editor.closed', {'id': id, 'path': buf.path});
}
+165
View File
@@ -0,0 +1,165 @@
/// Resolves external supporter binaries (claude, d2, …) to absolute paths
/// (T-495, D-104).
///
/// Order: an explicit user-scope **override** map is consulted FIRST; then the
/// login-shell / process search PATH ([resolvedToolPath]); then the well-known
/// user/local bin dirs — including **Homebrew-on-Linux** (`/home/linuxbrew/...`),
/// which the standard PATH expansion omits and a login-shell probe misses when
/// `brew shellenv` lives only in `~/.bashrc`. That omission is the gap D-104
/// fixes.
///
/// [detect] probes those locations to seed the override map on first run (or a
/// re-detect), so resolution is **pinned** thereafter — deterministic, not a
/// per-launch heuristic. A pin that no longer points at a file is reported by
/// [isStalePin] so the caller can warn + re-detect rather than silently fail.
///
/// Pure Dart (injected `exists` / `searchPath`), Flutter-free; runs under
/// `dart test`.
library;
import 'dart:io';
import 'shell_env.dart' show resolvedToolPath;
/// Whether [absolutePath] points at an existing file (symlinks followed).
typedef PathExists = bool Function(String absolutePath);
class SupporterBinaries {
SupporterBinaries({Map<String, String> overrides = const {}, PathExists? exists, String Function()? searchPath, String? home, bool isWindows = false})
: _overrides = overrides,
_exists = exists ?? _fileExists,
_searchPath = searchPath ?? resolvedToolPath,
_home = home ?? Platform.environment['HOME'],
_sep = isWindows ? ';' : ':';
final Map<String, String> _overrides;
final PathExists _exists;
final String Function() _searchPath;
final String? _home;
final String _sep;
/// Resolve [name] to an absolute path, or `null`. Order: explicit override (if
/// it still exists) → search PATH → well-known dirs.
String? resolve(String name) {
final pinned = _overrides[name];
if (pinned != null && pinned.isNotEmpty && _exists(pinned)) return pinned;
for (final dir in _searchDirs()) {
final p = '$dir/$name';
if (_exists(p)) return p;
}
return null;
}
/// True when [name]'s override is set but no longer points at a file — a stale
/// pin (the tool moved on an upgrade). The caller warns and can re-detect.
bool isStalePin(String name) {
final p = _overrides[name];
return p != null && p.isNotEmpty && !_exists(p);
}
/// Probe for [names], returning name→absolute-path for those found — the seed
/// for the override map on first run / re-detect.
Map<String, String> detect(Iterable<String> names) {
final dirs = _searchDirs().toList();
final out = <String, String>{};
for (final name in names) {
for (final dir in dirs) {
final p = '$dir/$name';
if (_exists(p)) {
out[name] = p;
break;
}
}
}
return out;
}
Iterable<String> _searchDirs() sync* {
final seen = <String>{};
for (final dir in _searchPath().split(_sep)) {
if (dir.isNotEmpty && seen.add(dir)) yield dir;
}
for (final dir in _wellKnownDirs()) {
if (seen.add(dir)) yield dir;
}
}
List<String> _wellKnownDirs() {
final h = _home ?? '';
return [if (h.isNotEmpty) '$h/.local/bin', '/usr/local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/home/linuxbrew/.linuxbrew/bin'];
}
}
bool _fileExists(String p) => File(p).existsSync();
/// The process-wide resolver, wired at boot via [loadSupporterBinaries]. Tool
/// consumers (e.g. the d2 template, T-494) read it to resolve a binary; null in
/// headless contexts where boot wiring hasn't run.
SupporterBinaries? activeSupporterBinaries;
/// User-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding the
/// explicit override path for tool [name]. One key per tool so the settings
/// panel binds a plain text field to each (T-414 / D-104).
String supporterToolKey(String name) => 'app.tools.$name';
/// Marker key recording that first-run auto-detection has run, so a later launch
/// neither re-probes nor clobbers a path the user cleared on purpose.
const supporterDetectedKey = 'app.tools.detected';
/// The external supporter binaries clide auto-detects (pql/git are bundled per
/// D-58/D-59 and excluded).
const knownSupporterTools = ['claude', 'd2'];
/// Build a [SupporterBinaries] from the per-tool override keys, auto-detecting
/// the as-yet-unconfigured tools ONCE on first run and pinning what it finds
/// (D-104). [read] returns a stored value (or null); [write] persists one.
/// Injected (not the SettingsStore directly) so this stays Flutter-free and
/// `dart test`-able; [detect] is the prober.
Future<SupporterBinaries> loadSupporterBinaries({
required Object? Function(String key) read,
required Future<void> Function(String key, Object? value) write,
List<String> tools = knownSupporterTools,
SupporterBinaries Function()? detect,
}) async {
final overrides = _readOverrides(read, tools);
if (read(supporterDetectedKey) == null) {
// First run: probe the tools without an explicit path, pin what's found.
final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools.where((t) => !overrides.containsKey(t)));
for (final e in fresh.entries) {
await write(supporterToolKey(e.key), e.value);
overrides[e.key] = e.value;
}
await write(supporterDetectedKey, true);
}
return SupporterBinaries(overrides: overrides);
}
/// Re-probe every tool and overwrite its override key (clearing one no longer
/// found), returning a fresh resolver. Backs the "Re-detect" settings action.
Future<SupporterBinaries> redetectSupporterBinaries({
required Future<void> Function(String key, Object? value) write,
List<String> tools = knownSupporterTools,
SupporterBinaries Function()? detect,
}) async {
final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools);
for (final t in tools) {
await write(supporterToolKey(t), fresh[t]);
}
await write(supporterDetectedKey, true);
return SupporterBinaries(overrides: fresh);
}
/// A resolver built from the current per-tool override keys, with no detect or
/// marker side effects — for rebuilding [activeSupporterBinaries] live when a
/// path is edited in settings.
SupporterBinaries supporterBinariesFrom(Object? Function(String key) read, {List<String> tools = knownSupporterTools}) =>
SupporterBinaries(overrides: _readOverrides(read, tools));
Map<String, String> _readOverrides(Object? Function(String) read, List<String> tools) {
final overrides = <String, String>{};
for (final t in tools) {
final v = read(supporterToolKey(t));
if (v is String && v.isNotEmpty) overrides[t] = v;
}
return overrides;
}
+12
View File
@@ -18,6 +18,18 @@ class GitClient {
final ToolchainView toolchain;
final Directory workDir;
/// Initialize a new git repository in [workDir] (T-487) — the backing of the
/// new-project flow, since clide treats a git repo as the workspace. The
/// directory must already exist. `-b <defaultBranch>` keeps the initial branch
/// deterministic rather than dependent on the user's git config. Idempotent:
/// `git init` on an existing repo is a no-op.
Future<void> init({String defaultBranch = 'main'}) async {
final r = await _run(['init', '-b', defaultBranch]);
if (r.exitCode != 0) {
throw GitException('git init failed', stderr: r.stderr.toString());
}
}
// -- queries --------------------------------------------------------------
Future<GitStatus> status() async {
+99
View File
@@ -0,0 +1,99 @@
/// Force-directed graph layout (T-323) — a clide-owned Fruchterman-Reingold
/// solver (own-the-rendering-stack: no layout package).
///
/// Nodes repel each other (an inverse-distance "Coulomb" force); edges pull
/// their endpoints together (a "spring"). Iterating with a cooling temperature
/// settles the graph into a readable layout. DETERMINISTIC — a fixed circular
/// seed (no RNG) means the same graph always lays out identically, so the view
/// is stable across rebuilds and the solver is unit-testable.
///
/// Flutter-free: pure Dart (dart:math), runs under `dart test`. The graph PANE
/// (rendering, pan/zoom, hover, filter) builds on top of this.
library;
import 'dart:math' as math;
/// A laid-out 2D point.
typedef GraphPoint = ({double x, double y});
class _Vec {
_Vec(this.x, this.y);
double x, y;
}
class ForceLayout {
/// Lay out [nodeIds] connected by [edges] (pairs of node ids) in a
/// [width]×[height] area over [iterations] steps. Edges referencing an unknown
/// node are ignored. Returns each node's settled position, clamped to the area.
static Map<String, GraphPoint> compute(List<String> nodeIds, List<(String, String)> edges, {double width = 800, double height = 600, int iterations = 200}) {
final n = nodeIds.length;
if (n == 0) return const {};
final cx = width / 2, cy = height / 2;
if (n == 1) return {nodeIds.first: (x: cx, y: cy)};
// Deterministic circular seed.
final pos = <String, _Vec>{};
for (var i = 0; i < n; i++) {
final a = 2 * math.pi * i / n;
pos[nodeIds[i]] = _Vec(cx + math.cos(a) * width / 4, cy + math.sin(a) * height / 4);
}
final valid = edges.where((e) => pos.containsKey(e.$1) && pos.containsKey(e.$2) && e.$1 != e.$2).toList();
final k = math.sqrt(width * height / n); // ideal edge length
var temp = width / 10;
for (var iter = 0; iter < iterations; iter++) {
final disp = {for (final id in nodeIds) id: _Vec(0, 0)};
// Repulsion between every pair.
for (var i = 0; i < n; i++) {
for (var j = i + 1; j < n; j++) {
final a = pos[nodeIds[i]]!, b = pos[nodeIds[j]]!;
var dx = a.x - b.x, dy = a.y - b.y;
var dist = math.sqrt(dx * dx + dy * dy);
if (dist < 0.01) {
dx = 0.01 * (i.isEven ? 1 : -1);
dy = 0.01;
dist = 0.01;
}
final force = k * k / dist;
final ux = dx / dist, uy = dy / dist;
disp[nodeIds[i]]!
..x += ux * force
..y += uy * force;
disp[nodeIds[j]]!
..x -= ux * force
..y -= uy * force;
}
}
// Attraction along edges.
for (final e in valid) {
final a = pos[e.$1]!, b = pos[e.$2]!;
final dx = a.x - b.x, dy = a.y - b.y;
final dist = math.max(0.01, math.sqrt(dx * dx + dy * dy));
final force = dist * dist / k;
final ux = dx / dist, uy = dy / dist;
disp[e.$1]!
..x -= ux * force
..y -= uy * force;
disp[e.$2]!
..x += ux * force
..y += uy * force;
}
// Apply, capped by the temperature, clamped to the area.
for (final id in nodeIds) {
final d = disp[id]!;
final len = math.max(0.01, math.sqrt(d.x * d.x + d.y * d.y));
final step = math.min(len, temp);
final p = pos[id]!;
p.x = (p.x + d.x / len * step).clamp(0.0, width);
p.y = (p.y + d.y / len * step).clamp(0.0, height);
}
temp *= 0.95; // cool
}
return {for (final id in nodeIds) id: (x: pos[id]!.x, y: pos[id]!.y)};
}
}
+9 -2
View File
@@ -156,8 +156,15 @@ class CommandSchema {
final out = <String, Object?>{};
final pos = raw['positional'];
if (pos is List) {
for (var i = 0; i < pos.length && i < positional.length; i++) {
out[positional[i]] = pos[i];
for (var i = 0; i < positional.length; i++) {
final name = positional[i];
// A trailing stringList positional is variadic: it absorbs ALL the
// remaining tokens (e.g. `icon show gear folder gauge`), not just one.
if (i == positional.length - 1 && args[name]?.type == ArgType.stringList) {
out[name] = i < pos.length ? pos.sublist(i) : const <Object?>[];
break;
}
if (i < pos.length) out[name] = pos[i];
}
}
final flags = raw['flags'];
+76 -18
View File
@@ -70,7 +70,15 @@ class _McpSession {
/// HTTP + SSE MCP server. Lifecycle mirrors [IpcServer]: `start()`
/// binds + writes the discovery file; `stop()` unbinds + removes it.
class McpServer {
McpServer({required this.workspaceRoot, required this.log, this.dispatcher, this.discoveryDirOverride, this.bindHost = '127.0.0.1', this.bindPort = 0});
McpServer({
required this.workspaceRoot,
required this.log,
this.dispatcher,
this.discoveryDirOverride,
this.boundConfigDir,
this.bindHost = '127.0.0.1',
this.bindPort = 0,
});
/// Workspace root reported in the discovery file. Helps Claude
/// Code show "which clide is this" when multiple are running.
@@ -86,6 +94,14 @@ class McpServer {
/// passes null; tests inject a tempdir.
final String? discoveryDirOverride;
/// Returns the Claude config dir bound to this workspace (T-479/T-480), or
/// null when unbound. When non-null, a copy of the discovery lock is also
/// written into that dir's `ide/`. A `claude` started with a custom
/// `CLAUDE_CONFIG_DIR` looks for its `/ide` lock under that dir, not under
/// `~/.claude/ide`, so without this its IDE bridge can't reach clide. Lazy:
/// main.dart passes a closure resolved against the (post-boot) AccountRegistry.
final String? Function()? boundConfigDir;
/// Bind host. localhost-only by default per D-73 (no remote
/// access; the threat model matches D-71's `0600`).
final String bindHost;
@@ -94,7 +110,11 @@ class McpServer {
final int bindPort;
HttpServer? _http;
String? _lockFile;
/// Every discovery-lock path this process has written — the default `ide/`
/// dir plus any bound-account `ide/` dirs. Reconciled by [syncDiscoveryLocks];
/// all are removed on [stop] so no orphan locks survive (T-479).
final Set<String> _lockFiles = {};
int? _port;
String? _authToken;
final Map<String, _McpSession> _sessions = {};
@@ -102,7 +122,13 @@ class McpServer {
bool get isRunning => _http != null;
int? get port => _port;
String? get lockFilePath => _lockFile;
String get _defaultIdeDir => discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
String get _defaultLockPath => '$_defaultIdeDir/$pid.lock';
/// The lock in the default `~/.claude/ide` dir (the one Claude finds without
/// `CLAUDE_CONFIG_DIR`). Null until [start] writes it.
String? get lockFilePath => _lockFiles.contains(_defaultLockPath) ? _defaultLockPath : null;
/// The per-start bearer token clients must present in [kMcpAuthHeader].
/// Published to legitimate clients via the 0600 lock file only.
@@ -114,7 +140,7 @@ class McpServer {
_http = server;
_port = server.port;
_authToken = _generateToken();
_lockFile = await _writeDiscoveryFile();
await syncDiscoveryLocks();
server.listen(
_route,
onError: (Object e, StackTrace st) {
@@ -134,16 +160,10 @@ class McpServer {
}
_sessions.clear();
await s.close(force: true);
final lock = _lockFile;
_lockFile = null;
if (lock != null) {
try {
final f = File(lock);
if (f.existsSync()) f.deleteSync();
} catch (e) {
log.warn('mcp', 'failed to unlink lock $lock: $e');
}
for (final lock in _lockFiles) {
_deleteLock(lock);
}
_lockFiles.clear();
}
// -- routing --------------------------------------------------------------
@@ -340,13 +360,43 @@ class McpServer {
// -- discovery file -------------------------------------------------------
Future<String> _writeDiscoveryFile() async {
final dir = discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
final dirHandle = Directory(dir);
/// The `ide/` dirs a discovery lock should currently live in: the default
/// `~/.claude/ide` always, plus the bound account's `<dir>/ide` when this
/// workspace is bound to a non-default account (T-479). Deduped, order-stable.
List<String> _activeIdeDirs() {
final dirs = <String>[_defaultIdeDir];
final bound = boundConfigDir?.call();
if (bound != null && bound.isNotEmpty) {
final accountIde = '$bound/ide';
if (!dirs.contains(accountIde)) dirs.add(accountIde);
}
return dirs;
}
/// Reconcile the on-disk discovery locks with the currently-active `ide/`
/// dirs (T-479): write a lock into each active dir, and remove any this
/// process wrote into a dir that is no longer active. Called on [start] and
/// whenever a per-repo account binding changes. No-op while not running.
Future<void> syncDiscoveryLocks() async {
if (!isRunning) return;
final want = {for (final d in _activeIdeDirs()) '$d/$pid.lock'};
for (final path in _lockFiles.difference(want).toList()) {
_deleteLock(path);
_lockFiles.remove(path);
}
for (final path in want.difference(_lockFiles).toList()) {
await _writeLockAt(path);
_lockFiles.add(path);
}
}
/// Write (or overwrite) the discovery lock at [path] — same content in every
/// dir — creating the `ide/` parent and 0600-scoping the file (T-362).
Future<void> _writeLockAt(String path) async {
final dirHandle = File(path).parent;
if (!dirHandle.existsSync()) {
dirHandle.createSync(recursive: true);
}
final path = '$dir/$pid.lock';
final body = jsonEncode({
'pid': pid,
'workspace': workspaceRoot,
@@ -364,7 +414,15 @@ class McpServer {
// ~/.claude which the home-dir perms usually already protect. But say so.
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
}
return path;
}
void _deleteLock(String path) {
try {
final f = File(path);
if (f.existsSync()) f.deleteSync();
} catch (e) {
log.warn('mcp', 'failed to unlink lock $path: $e');
}
}
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
+36
View File
@@ -82,10 +82,14 @@ class IpcServer {
/// listening on the path the bind throws — the caller is the
/// stale-vs-live arbiter (per D-72 there's one server per
/// workspace; a colliding live process means a real conflict).
/// Orphaned sockets from crashed instances of OTHER workspaces are
/// also swept from the runtime dir on startup (T-247), so the dir
/// doesn't accumulate dead nodes.
Future<void> start() async {
if (isRunning) return;
final path = workspaceSocketPath(workspaceRoot);
await _prepareParentDir(path);
await _sweepStaleSockets(path);
await _unlinkStale(path);
final socket = await ServerSocket.bind(InternetAddress(path, type: InternetAddressType.unix), 0);
try {
@@ -249,6 +253,38 @@ class IpcServer {
}
}
/// Sweep the runtime socket dir for orphaned `*.sock` nodes left by crashed
/// instances of OTHER workspaces (T-247): probe each, unlink only the dead
/// ones. A live instance (something answers) or an unresponsive node (could
/// be a hung instance) is left untouched; the current workspace's own path is
/// handled by [_unlinkStale]. Best-effort — a sweep failure never blocks our
/// own startup.
Future<void> _sweepStaleSockets(String selfPath) async {
try {
final dir = Directory(File(selfPath).parent.path);
if (!dir.existsSync()) return;
for (final entry in dir.listSync()) {
if (entry is! File || !entry.path.endsWith('.sock') || entry.path == selfPath) continue;
try {
final probe = await Socket.connect(InternetAddress(entry.path, type: InternetAddressType.unix), 0).timeout(const Duration(milliseconds: 200));
await probe.close(); // live instance — leave it alone
} on SocketException {
// No listener — an orphan from a crashed instance. Unlink it.
try {
entry.deleteSync();
log.info('ipc', 'swept orphaned socket ${entry.path}');
} catch (e) {
log.warn('ipc', 'failed to sweep ${entry.path}: $e');
}
} on TimeoutException {
// Exists but unresponsive — possibly a hung instance; don't clobber.
}
}
} catch (e) {
log.warn('ipc', 'socket sweep failed: $e');
}
}
Future<void> _unlinkStale(String path) async {
final f = File(path);
if (!f.existsSync()) return;
+117
View File
@@ -0,0 +1,117 @@
/// SVG / CSS colour parsing for the renderer (T-320 / D-103).
///
/// Converts an SVG colour string into a packed `0xAARRGGBB` int the painter can
/// hand to a `dart:ui` Color. Handles `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`,
/// `rgb()/rgba()` (integer or percentage channels), the common named colours,
/// and `none`/`transparent` (→ fully transparent, so the painter simply skips
/// it). Returns `null` for anything unrecognised so the caller can fall back to
/// the inherited / default paint. Never throws.
///
/// A colour is CONTENT, not a clide theme token (D-103 / D-7): an SVG fill is
/// whatever the document says, independent of clide's palette.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
/// Parse an SVG colour to packed ARGB (`0xAARRGGBB`); `null` if unrecognised.
/// `none` / `transparent` → `0x00000000`.
int? parseSvgColor(String raw) {
final s = raw.trim().toLowerCase();
if (s.isEmpty) return null;
if (s == 'none' || s == 'transparent') return 0x00000000;
if (s.startsWith('#')) return _hex(s.substring(1));
if (s.startsWith('rgb')) return _rgb(s);
return _named[s];
}
int? _hex(String h) {
String dbl(String c) => '$c$c';
String rr, gg, bb, aa;
switch (h.length) {
case 3:
rr = dbl(h[0]);
gg = dbl(h[1]);
bb = dbl(h[2]);
aa = 'ff';
case 4:
rr = dbl(h[0]);
gg = dbl(h[1]);
bb = dbl(h[2]);
aa = dbl(h[3]);
case 6:
rr = h.substring(0, 2);
gg = h.substring(2, 4);
bb = h.substring(4, 6);
aa = 'ff';
case 8:
rr = h.substring(0, 2);
gg = h.substring(2, 4);
bb = h.substring(4, 6);
aa = h.substring(6, 8);
default:
return null;
}
final r = int.tryParse(rr, radix: 16);
final g = int.tryParse(gg, radix: 16);
final b = int.tryParse(bb, radix: 16);
final a = int.tryParse(aa, radix: 16);
if (r == null || g == null || b == null || a == null) return null;
return (a << 24) | (r << 16) | (g << 8) | b;
}
int? _rgb(String s) {
final open = s.indexOf('('), close = s.indexOf(')');
if (open < 0 || close < open) return null;
final parts = s.substring(open + 1, close).split(',').map((p) => p.trim()).toList();
if (parts.length < 3) return null;
int chan(String p) {
if (p.endsWith('%')) {
final pct = double.tryParse(p.substring(0, p.length - 1)) ?? 0;
return (pct / 100 * 255).round().clamp(0, 255);
}
return (double.tryParse(p) ?? 0).round().clamp(0, 255);
}
final r = chan(parts[0]), g = chan(parts[1]), b = chan(parts[2]);
var a = 255;
if (parts.length >= 4) {
final af = double.tryParse(parts[3]);
if (af != null) a = (af * 255).round().clamp(0, 255);
}
return (a << 24) | (r << 16) | (g << 8) | b;
}
/// Common named colours (the CSS basics plus a few greys d2/graphviz emit).
/// Extended names can be added as needed — d2 uses hex, so this is mostly for
/// hand-authored SVG.
const Map<String, int> _named = {
'black': 0xFF000000,
'white': 0xFFFFFFFF,
'red': 0xFFFF0000,
'lime': 0xFF00FF00,
'green': 0xFF008000,
'blue': 0xFF0000FF,
'yellow': 0xFFFFFF00,
'cyan': 0xFF00FFFF,
'aqua': 0xFF00FFFF,
'magenta': 0xFFFF00FF,
'fuchsia': 0xFFFF00FF,
'silver': 0xFFC0C0C0,
'gray': 0xFF808080,
'grey': 0xFF808080,
'maroon': 0xFF800000,
'olive': 0xFF808000,
'teal': 0xFF008080,
'navy': 0xFF000080,
'purple': 0xFF800080,
'orange': 0xFFFFA500,
'pink': 0xFFFFC0CB,
'brown': 0xFFA52A2A,
'gold': 0xFFFFD700,
'lightgray': 0xFFD3D3D3,
'lightgrey': 0xFFD3D3D3,
'darkgray': 0xFFA9A9A9,
'darkgrey': 0xFFA9A9A9,
'whitesmoke': 0xFFF5F5F5,
};
+317
View File
@@ -0,0 +1,317 @@
/// Builds the typed [SvgDocument] scene from raw SVG text (T-320 / D-103).
///
/// Pipeline: [parseXml] → [inlineStyles] (flatten classes to inline attrs) →
/// walk the tree into typed [SvgNode]s, resolving each element's geometry,
/// `transform` ([Affine]), and presentation [SvgStyle] with inheritance applied
/// down the tree. Colours resolve to packed ARGB ([parseSvgColor]); paths to
/// [SvgPathSeg]s ([parseSvgPath]).
///
/// Tolerant: a non-`<svg>` root yields [SvgDocument.empty]; unknown elements
/// (and `defs`/`marker`, deferred this slice) are skipped. Never throws.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'svg_color.dart';
import 'svg_node.dart';
import 'svg_path.dart';
import 'svg_style.dart';
import 'svg_transform.dart';
import 'svg_xml.dart';
/// Parse raw SVG into the typed scene model.
SvgDocument buildSvgDocument(String src) {
final root = parseXml(src);
if (root == null || root.name != 'svg') return SvgDocument.empty;
inlineStyles(root);
final markers = <String, SvgMarker>{};
_collectMarkers(root, markers);
final annotations = <SvgAnnotation>[];
final style = _resolveStyle(root.attrs, SvgStyle.initial);
final rootTf = _transform(root.attrs['transform']);
final children = _children(root, style, rootTf ?? Affine.identity, annotations);
return SvgDocument(
width: _lenN(root.attrs['width']),
height: _lenN(root.attrs['height']),
viewBox: _viewBox(root.attrs['viewBox']),
root: SvgGroup(style, rootTf, children),
markers: markers,
annotations: annotations,
);
}
void _collectMarkers(XmlElement el, Map<String, SvgMarker> into) {
if (el.name == 'marker') {
final id = el.attrs['id'];
if (id != null && id.isNotEmpty) into[id] = _marker(el);
}
for (final c in el.children) {
if (c is XmlElement) _collectMarkers(c, into);
}
}
SvgMarker _marker(XmlElement el) {
final orient = el.attrs['orient'];
final auto = orient == 'auto' || orient == 'auto-start-reverse';
return SvgMarker(
refX: _num(el.attrs['refX']),
refY: _num(el.attrs['refY']),
orientAuto: auto,
orientAngle: (orient != null && !auto) ? (_stripNum(orient) ?? 0) : 0,
strokeScaled: el.attrs['markerUnits'] != 'userSpaceOnUse', // default = strokeWidth
viewBox: _viewBox(el.attrs['viewBox']),
children: _children(el, SvgStyle.initial, Affine.identity, []), // markers don't carry annotations
);
}
/// Extract the id from a `marker-*` value like `url(#id)`.
String? _markerRef(String? v) {
if (v == null) return null;
final m = RegExp(r'url\(\s*#([^)\s]+)\s*\)').firstMatch(v);
return m?.group(1) ?? (v.startsWith('#') ? v.substring(1) : null);
}
List<SvgNode> _children(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
final out = <SvgNode>[];
for (final c in el.children) {
if (c is XmlElement) {
final n = _node(c, inherited, accumulated, annotations);
if (n != null) out.add(n);
}
}
return out;
}
SvgNode? _node(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
final style = _resolveStyle(el.attrs, inherited);
final tf = _transform(el.attrs['transform']);
final acc = tf == null ? accumulated : accumulated.multiply(tf);
final node = _build(el, style, tf, acc, annotations);
if (node != null) _maybeAnnotate(el, node, acc, annotations);
return node;
}
SvgNode? _build(XmlElement el, SvgStyle style, Affine? tf, Affine acc, List<SvgAnnotation> annotations) {
final a = el.attrs;
switch (el.name) {
case 'g':
case 'a':
case 'svg':
return SvgGroup(style, tf, _children(el, style, acc, annotations));
case 'rect':
final rx = _numN(a['rx']), ry = _numN(a['ry']);
return SvgRect(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), rx ?? ry ?? 0, ry ?? rx ?? 0);
case 'circle':
final r = _num(a['r']);
return SvgEllipse(style, tf, _num(a['cx']), _num(a['cy']), r, r);
case 'ellipse':
return SvgEllipse(style, tf, _num(a['cx']), _num(a['cy']), _num(a['rx']), _num(a['ry']));
case 'line':
return SvgLine(style, tf, _num(a['x1']), _num(a['y1']), _num(a['x2']), _num(a['y2']));
case 'polyline':
return SvgPolyline(style, tf, _points(a['points']), false);
case 'polygon':
return SvgPolyline(style, tf, _points(a['points']), true);
case 'path':
return SvgPath(
style,
tf,
parseSvgPath(a['d'] ?? ''),
markerStart: _markerRef(a['marker-start']),
markerMid: _markerRef(a['marker-mid']),
markerEnd: _markerRef(a['marker-end']),
);
case 'text':
return SvgText(style, tf, _num(a['x']), _num(a['y']), _textOf(el));
case 'image':
return SvgImage(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), a['href'] ?? a['xlink:href'] ?? '');
default:
return null; // defs, marker, title, desc, unknown — skipped
}
}
void _maybeAnnotate(XmlElement el, SvgNode node, Affine acc, List<SvgAnnotation> annotations) {
final label = el.attrs['data-label'];
final desc = el.attrs['data-description'];
final lightbox = el.attrs.containsKey('data-lightbox');
if (label == null && desc == null && !lightbox) return;
final box = _localBBox(node);
if (box == null) return;
final r = _transformedAABB(box, acc);
annotations.add(
SvgAnnotation(x: r[0], y: r[1], width: r[2], height: r[3], label: label, description: desc, lightbox: lightbox, href: node is SvgImage ? node.href : null),
);
}
/// Axis-aligned bounding box of [node] in its own local coordinates. Groups are
/// not bounded (anchor leaf shapes); text degenerates to its anchor point.
List<double>? _localBBox(SvgNode node) {
switch (node) {
case SvgRect r:
return [r.x, r.y, r.width, r.height];
case SvgImage i:
return [i.x, i.y, i.width, i.height];
case SvgEllipse e:
return [e.cx - e.rx, e.cy - e.ry, e.rx * 2, e.ry * 2];
case SvgLine l:
return _aabbOfPoints([l.x1, l.y1, l.x2, l.y2]);
case SvgPolyline p:
return _aabbOfPoints(p.points);
case SvgPath p:
return _aabbOfPoints(_pathPoints(p.segments));
case SvgText t:
return [t.x, t.y, 0, 0];
case SvgGroup _:
return null;
}
}
List<double> _pathPoints(List<SvgPathSeg> segs) {
final pts = <double>[];
for (final s in segs) {
switch (s.op) {
case SvgPathOp.moveTo:
case SvgPathOp.lineTo:
pts.addAll([s.args[0], s.args[1]]);
case SvgPathOp.cubicTo:
pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]]);
case SvgPathOp.quadTo:
pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3]]);
case SvgPathOp.arcTo:
pts.addAll([s.args[5], s.args[6]]);
case SvgPathOp.close:
break;
}
}
return pts;
}
List<double>? _aabbOfPoints(List<double> pts) {
if (pts.length < 2) return null;
var minX = pts[0], minY = pts[1], maxX = pts[0], maxY = pts[1];
for (var i = 0; i + 1 < pts.length; i += 2) {
minX = pts[i] < minX ? pts[i] : minX;
maxX = pts[i] > maxX ? pts[i] : maxX;
minY = pts[i + 1] < minY ? pts[i + 1] : minY;
maxY = pts[i + 1] > maxY ? pts[i + 1] : maxY;
}
return [minX, minY, maxX - minX, maxY - minY];
}
/// Transform [box] = `[x, y, w, h]` by [m]; return the AABB `[x, y, w, h]`.
List<double> _transformedAABB(List<double> box, Affine m) {
final x = box[0], y = box[1], w = box[2], h = box[3];
final corners = [m.apply(x, y), m.apply(x + w, y), m.apply(x, y + h), m.apply(x + w, y + h)];
var minX = corners[0].$1, minY = corners[0].$2, maxX = corners[0].$1, maxY = corners[0].$2;
for (final c in corners) {
minX = c.$1 < minX ? c.$1 : minX;
maxX = c.$1 > maxX ? c.$1 : maxX;
minY = c.$2 < minY ? c.$2 : minY;
maxY = c.$2 > maxY ? c.$2 : maxY;
}
return [minX, minY, maxX - minX, maxY - minY];
}
SvgStyle _resolveStyle(Map<String, String> a, SvgStyle inh) {
int? color(String k, int? fb) {
final v = a[k];
return v == null ? fb : (parseSvgColor(v) ?? fb);
}
double? dbl(String k, double? fb) {
final v = a[k];
return v == null ? fb : (_stripNum(v) ?? fb);
}
return SvgStyle(
fill: color('fill', inh.fill),
stroke: color('stroke', inh.stroke),
strokeWidth: dbl('stroke-width', inh.strokeWidth),
opacity: _stripNum(a['opacity'] ?? '') ?? 1.0, // not inherited
fillOpacity: dbl('fill-opacity', inh.fillOpacity),
strokeOpacity: dbl('stroke-opacity', inh.strokeOpacity),
dashArray: a.containsKey('stroke-dasharray') ? _dash(a['stroke-dasharray']!) : inh.dashArray,
lineCap: a.containsKey('stroke-linecap') ? _cap(a['stroke-linecap']!) : inh.lineCap,
lineJoin: a.containsKey('stroke-linejoin') ? _join(a['stroke-linejoin']!) : inh.lineJoin,
fontFamily: a['font-family'] ?? inh.fontFamily,
fontSize: dbl('font-size', inh.fontSize),
fontWeight: a.containsKey('font-weight') ? _weight(a['font-weight']!) : inh.fontWeight,
textAnchor: a.containsKey('text-anchor') ? _anchor(a['text-anchor']!) : inh.textAnchor,
baseline: a.containsKey('dominant-baseline') ? _baseline(a['dominant-baseline']!) : inh.baseline,
);
}
SvgLineCap _cap(String v) => switch (v.trim()) {
'round' => SvgLineCap.round,
'square' => SvgLineCap.square,
_ => SvgLineCap.butt,
};
SvgLineJoin _join(String v) => switch (v.trim()) {
'round' => SvgLineJoin.round,
'bevel' => SvgLineJoin.bevel,
_ => SvgLineJoin.miter,
};
SvgTextAnchor _anchor(String v) => switch (v.trim()) {
'middle' => SvgTextAnchor.middle,
'end' => SvgTextAnchor.end,
_ => SvgTextAnchor.start,
};
SvgBaseline _baseline(String v) => switch (v.trim()) {
'middle' || 'central' => SvgBaseline.middle,
'hanging' || 'text-before-edge' => SvgBaseline.hanging,
_ => SvgBaseline.auto,
};
int _weight(String v) => switch (v.trim()) {
'bold' => 700,
'normal' => 400,
_ => int.tryParse(v.trim()) ?? 400,
};
List<double> _dash(String v) {
if (v.trim() == 'none') return const [];
return v.split(RegExp(r'[\s,]+')).map(_stripNum).whereType<double>().toList();
}
List<double> _points(String? v) {
if (v == null) return const [];
return v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).whereType<double>().toList();
}
SvgViewBox? _viewBox(String? v) {
if (v == null) return null;
final n = v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).toList();
if (n.length < 4 || n.any((x) => x == null)) return null;
return SvgViewBox(n[0]!, n[1]!, n[2]!, n[3]!);
}
Affine? _transform(String? v) {
if (v == null || v.isEmpty) return null;
final t = parseTransform(v);
return t.isIdentity ? null : t;
}
String _textOf(XmlElement el) {
final buf = StringBuffer();
for (final n in el.descendants()) {
if (n is XmlText) buf.write(n.text);
}
return buf.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
}
/// Parse a length, ignoring a trailing unit (`12px` → 12); `null` if no number.
double? _stripNum(String v) {
final m = RegExp(r'[-+]?(?:[0-9]*\.[0-9]+|[0-9]+)(?:[eE][-+]?[0-9]+)?').firstMatch(v.trim());
return m == null ? null : double.tryParse(m.group(0)!);
}
double _num(String? v) => v == null ? 0 : (_stripNum(v) ?? 0);
double? _numN(String? v) => v == null ? null : _stripNum(v);
double? _lenN(String? v) => _numN(v);
+185
View File
@@ -0,0 +1,185 @@
/// The typed SVG scene model the painter draws (T-320 / D-103).
///
/// The document builder ([buildSvgDocument]) lowers a normalized XML tree into
/// this model: every node carries a resolved [SvgStyle] (inheritance already
/// flattened), an optional [Affine] transform, and typed geometry. Colours are
/// packed ARGB ints, not `dart:ui` Colors, so the model stays Flutter-free and
/// `dart test`-able; the painter converts.
///
/// `null` style fields mean "unspecified" — the painter applies the SVG default
/// (fill black, stroke none, stroke-width 1).
library;
import 'svg_path.dart';
import 'svg_transform.dart';
enum SvgLineCap { butt, round, square }
enum SvgLineJoin { miter, round, bevel }
enum SvgTextAnchor { start, middle, end }
enum SvgBaseline { auto, middle, hanging }
/// Resolved presentation style for a node, with inheritance already applied.
class SvgStyle {
const SvgStyle({
this.fill,
this.stroke,
this.strokeWidth,
this.opacity = 1.0,
this.fillOpacity,
this.strokeOpacity,
this.dashArray,
this.lineCap,
this.lineJoin,
this.fontFamily,
this.fontSize,
this.fontWeight,
this.textAnchor,
this.baseline,
});
/// The root inheritance context: nothing specified, full opacity.
static const initial = SvgStyle();
final int? fill; // ARGB; 0x00000000 = explicit none
final int? stroke; // ARGB
final double? strokeWidth;
final double opacity; // element/group opacity — NOT inherited
final double? fillOpacity;
final double? strokeOpacity;
final List<double>? dashArray;
final SvgLineCap? lineCap;
final SvgLineJoin? lineJoin;
final String? fontFamily;
final double? fontSize;
final int? fontWeight; // 400, 700, …
final SvgTextAnchor? textAnchor;
final SvgBaseline? baseline;
}
/// A node in the typed scene — group or leaf. [style] is fully resolved;
/// [transform] is `null` when identity.
sealed class SvgNode {
const SvgNode(this.style, this.transform);
final SvgStyle style;
final Affine? transform;
}
class SvgGroup extends SvgNode {
const SvgGroup(super.style, super.transform, this.children);
final List<SvgNode> children;
}
class SvgRect extends SvgNode {
const SvgRect(super.style, super.transform, this.x, this.y, this.width, this.height, this.rx, this.ry);
final double x, y, width, height, rx, ry;
}
/// Circle is an ellipse with `rx == ry`.
class SvgEllipse extends SvgNode {
const SvgEllipse(super.style, super.transform, this.cx, this.cy, this.rx, this.ry);
final double cx, cy, rx, ry;
}
class SvgLine extends SvgNode {
const SvgLine(super.style, super.transform, this.x1, this.y1, this.x2, this.y2);
final double x1, y1, x2, y2;
}
/// Polyline (open) or polygon (`closed == true`). [points] is `[x0,y0,x1,y1,…]`.
class SvgPolyline extends SvgNode {
const SvgPolyline(super.style, super.transform, this.points, this.closed);
final List<double> points;
final bool closed;
}
class SvgPath extends SvgNode {
const SvgPath(super.style, super.transform, this.segments, {this.markerStart, this.markerMid, this.markerEnd});
final List<SvgPathSeg> segments;
/// Ids (sans `url(#…)`) of `marker-start`/`mid`/`end` definitions, if any.
final String? markerStart, markerMid, markerEnd;
}
/// A `<marker>` definition (e.g. an arrowhead), referenced by `marker-*`.
class SvgMarker {
const SvgMarker({
required this.refX,
required this.refY,
required this.orientAuto,
required this.orientAngle,
required this.strokeScaled,
required this.children,
this.viewBox,
});
final double refX, refY;
final bool orientAuto; // orient="auto"
final double orientAngle; // fixed angle (degrees) when not auto
final bool strokeScaled; // markerUnits=strokeWidth (default) vs userSpaceOnUse
final SvgViewBox? viewBox;
final List<SvgNode> children;
}
class SvgText extends SvgNode {
const SvgText(super.style, super.transform, this.x, this.y, this.text);
final double x, y;
final String text;
}
class SvgImage extends SvgNode {
const SvgImage(super.style, super.transform, this.x, this.y, this.width, this.height, this.href);
final double x, y, width, height;
final String href;
}
/// `viewBox="minX minY width height"`.
class SvgViewBox {
const SvgViewBox(this.minX, this.minY, this.width, this.height);
final double minX, minY, width, height;
}
/// A per-object overlay annotation (T-318, D-103): a caption and/or lightbox
/// affordance anchored to an SVG element carrying `data-label` /
/// `data-description` / `data-lightbox`. The rect is the element's axis-aligned
/// bounding box in viewBox (user-space) coordinates with its transform applied;
/// the Flutter overlay maps it through the same viewBox→size fit the painter
/// uses, so captions sit under the right spot.
class SvgAnnotation {
const SvgAnnotation({
required this.x,
required this.y,
required this.width,
required this.height,
this.label,
this.description,
this.lightbox = false,
this.href,
});
final double x, y, width, height;
final String? label, description;
final bool lightbox;
final String? href; // image href, for a lightbox target
}
/// A parsed SVG document: optional intrinsic [width]/[height] (px), optional
/// [viewBox], the [root] group, marker defs, and per-object overlay
/// [annotations].
class SvgDocument {
const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}, this.annotations = const []});
static const empty = SvgDocument(root: SvgGroup(SvgStyle.initial, null, []));
final double? width, height;
final SvgViewBox? viewBox;
final SvgGroup root;
/// `<marker>` definitions by id, referenced by path `marker-*`.
final Map<String, SvgMarker> markers;
/// Overlay captions/lightbox anchors extracted from `data-*` attributes.
final List<SvgAnnotation> annotations;
}

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