Compare commits
@@ -5,19 +5,22 @@ lives in its own directory with a `SKILL.md` (frontmatter + body) and any
|
||||
helper scripts. This index is for humans skimming what's available; Claude
|
||||
discovers skills automatically from the directory structure.
|
||||
|
||||
Only clide-specific skills live in this repo. Repo-agnostic skills
|
||||
(`d2-diagram`, `frame0-wireframe`, `skill-create`) live at user scope
|
||||
(`~/.claude/skills/`), as do the pql-distributed ones (`pql`, `clean-house`,
|
||||
installed via `pql init`).
|
||||
|
||||
| Skill | Purpose |
|
||||
|---|---|
|
||||
| [`d2-diagram`](d2-diagram/SKILL.md) | Generate technical diagrams from `.d2` source with the d2 CLI; renders to PNG. |
|
||||
| [`frame0-wireframe`](frame0-wireframe/SKILL.md) | Author UI wireframes as local JSON and sync to the Frame0 desktop app for rendering + export. |
|
||||
| [`git-commit`](git-commit/SKILL.md) | Commit conventions for this repo — message style, CHANGELOG discipline (40/60 word cap), attribution trailer, safety rules. |
|
||||
| [`pql`](pql/SKILL.md) | Query and plan against the markdown vault via the `pql` CLI (decisions, tickets, structural queries). |
|
||||
| [`skill-create`](skill-create/SKILL.md) | Guidance for creating new skills — SKILL.md structure, bundling scripts, packaging. |
|
||||
| [`testmode`](testmode/SKILL.md) | Run and interpret the `ClideTestApp` platform integration harness; smoke-test after toolchain / IPC / theme / native changes. |
|
||||
| [`ui-design`](ui-design/SKILL.md) | Visual design guide — surface tokens, control geometry, Phosphor icons. |
|
||||
| [`clide`](clide/SKILL.md) | Observe/drive the live clide UI through the `clide` CLI; discover the surface with `clide capabilities`. |
|
||||
| [`git-commit`](git-commit/SKILL.md) | Commit conventions for this repo — message style, CHANGELOG discipline (60-word cap), attribution trailer, safety rules. |
|
||||
| [`testmode`](testmode/SKILL.md) | Run and interpret the `ClideTestApp` platform integration harness; smoke-test after toolchain / IPC / PTY / theme / native changes. |
|
||||
| [`ui-design`](ui-design/SKILL.md) | Visual design guide — surface tokens, control geometry, Phosphor icons (`byName` + generated glyph table). |
|
||||
| [`whats-next`](whats-next/SKILL.md) | Dependency-driven batch selection against the pql backlog. Walks the initiative/epic tree, filters unblocked work, refines, optionally activates. |
|
||||
|
||||
## Adding a skill
|
||||
|
||||
Use the `skill-create` skill (or follow its SKILL.md by hand). Add a row to
|
||||
the table above so the inventory stays accurate; the index is otherwise just
|
||||
a directory listing.
|
||||
Use the `skill-create` skill (user scope; or follow its SKILL.md by hand).
|
||||
Clide-specific skills go here; repo-agnostic ones go to `~/.claude/skills/`.
|
||||
Add a row to the table above so the inventory stays accurate; the index is
|
||||
otherwise just a directory listing.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"hash": "sha256:8090b99552c828ce019aa3740d8a0acb4768a443704b4e935efa23b32e91128d",
|
||||
"installed_at": "2026-06-02T08:46:25Z"
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
---
|
||||
name: clean-house
|
||||
description: >
|
||||
Run a documentation-discipline pass over a DQR (Decisions / Questions /
|
||||
Rejected) markdown system. Audits decisions/, surfaces drift (broken
|
||||
anchor links, missing Q→D backlinks, sunset-shaped phrases without
|
||||
linked tickets, files over the split threshold, dead cross-references),
|
||||
batches findings by category, and uses AskUserQuestion to apply fixes
|
||||
interactively. Trigger this skill whenever the user asks to clean,
|
||||
tidy, audit, sweep, garden, or review the state of decisions/, the
|
||||
DQR system, the questions index, or the records under decisions/.
|
||||
Also trigger before a release, after a session that touched many
|
||||
records, or whenever the user mentions documentation drift, stale
|
||||
plans, or "house" / "clean-house" / "house cleaning". Imperative —
|
||||
apply fixes, not just report.
|
||||
---
|
||||
|
||||
# clean-house
|
||||
|
||||
A periodic gardening pass over `decisions/`. The DQR system is
|
||||
markdown-as-source-of-truth; `.pql/pql.db` is a derived index. This skill
|
||||
operates on the markdown files, batches drift by category, and uses
|
||||
`AskUserQuestion` to decide what to apply.
|
||||
|
||||
The skill is **imperative**. It really cleans. The verb-noun match is
|
||||
load-bearing — a `clean-house` that only produced a report would be
|
||||
misnamed. Mechanical fixes get a single batched approval, judgment-call
|
||||
findings get individual prompts, and a "stop asking" escape hatch is
|
||||
always present.
|
||||
|
||||
You — Claude — are the runtime. Read this file, read
|
||||
`references/rules.md`, follow the procedure below, call `pql` and `Edit`
|
||||
to do the work, call `AskUserQuestion` for the prompts.
|
||||
|
||||
## When to run
|
||||
|
||||
- Before a release.
|
||||
- After a session that touched many D / Q / R records.
|
||||
- When something feels stale and a sweep is wanted.
|
||||
- When `pql decisions validate` is green but `decisions/` still feels off.
|
||||
|
||||
This skill is **not** a replacement for `pql decisions validate`.
|
||||
Validate is fast, hot, and gates pre-push. clean-house is slow, cold,
|
||||
on-demand. Escalation path: rules that fire often here are candidates
|
||||
for promotion to `pql decisions validate`; rules that almost never fire
|
||||
stay here where false positives don't slow anyone down.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Gate on validate.** Run `pql decisions validate`. If it exits
|
||||
non-zero, stop and tell the user to fix validator findings first.
|
||||
Do not proceed.
|
||||
|
||||
2. **Load the rule catalog.** Read `references/rules.md`. Each rule
|
||||
names its detection, fix, and finding-id format.
|
||||
|
||||
3. **Probe conventions.** Read `decisions/.clean-house.yaml` if it
|
||||
exists; otherwise infer from the project. Conventions to resolve:
|
||||
- `heading_level`: 2 or 3 — the depth records use (`## D-N` vs
|
||||
`### D-N`). Probe by sampling the first 3 records' file
|
||||
locations and looking for the first matching ATX heading.
|
||||
- `backlink_phrasing`: the project's Q→D backlink phrase (default
|
||||
`Resolved → D-N`; some projects use `Resolved as D-N` or
|
||||
`→ D-N`).
|
||||
- `file_threshold`: default 350 (RULE-FILE-OVER-THRESHOLD).
|
||||
- `stale_open_q_days`: default 60 (RULE-STALE-OPEN-Q).
|
||||
|
||||
Pass the resolved conventions into each rule's detection. Cache
|
||||
the probe result for the run.
|
||||
|
||||
`decisions/.clean-house.yaml` shape (all keys optional):
|
||||
```yaml
|
||||
heading_level: 3
|
||||
backlink_phrasing: "Resolved → "
|
||||
file_threshold: 500
|
||||
stale_open_q_days: 90
|
||||
exclude_paths: ["legacy/", "drafts/"]
|
||||
```
|
||||
|
||||
4. **Sync the index.** Run `pql decisions sync` so the DB reflects
|
||||
the markdown.
|
||||
|
||||
5. **Walk decisions/.** Use `pql decisions list` to enumerate
|
||||
records, plus `pql decisions read <id>` per record when a rule
|
||||
needs the body. For file-level rules (size, sort), read the
|
||||
markdown files directly. **Filter out** any record whose
|
||||
`file_path` begins with `legacy/` (or any prefix in
|
||||
`exclude_paths` from the probe).
|
||||
|
||||
6. **Run each rule's detection.** Collect findings. Tag each with
|
||||
its rule ID, finding ID (per the rule's format), category
|
||||
(`mechanical` | `judgment`), and the record(s)/file(s) involved.
|
||||
|
||||
7. **Group findings by rule** and prompt. Honor any "skipped 3+
|
||||
times" findings from the skip ledger by escalating their prompt
|
||||
("skipped 3x — promote to manual decision, downgrade to
|
||||
summary-only, or keep asking?"):
|
||||
|
||||
- **Mechanical rules** — one prompt per rule, batched. Show the
|
||||
count and offer: apply all / show diff first / skip this rule /
|
||||
**stop asking** (global — see below).
|
||||
- **Judgment rules** — one prompt per finding. Options match the
|
||||
rule's `Fix` section, plus the same global **stop asking**.
|
||||
|
||||
**"Stop asking" semantics (global).** When the user picks "stop
|
||||
asking" on any prompt, immediately halt all further prompts in
|
||||
the run — mechanical batches not yet asked AND remaining
|
||||
judgment findings. Move all unprompted findings to the skip
|
||||
ledger marked `deferred-stop-asking`. Skip to step 9.
|
||||
|
||||
**"Show diff first" loop.** When the user picks "show diff
|
||||
first" on a mechanical batch, emit a unified diff of the staged
|
||||
edits (one block per file), then re-prompt the same question
|
||||
with the same options minus "show diff first" — preventing
|
||||
infinite loops while still letting the user inspect before
|
||||
committing.
|
||||
|
||||
8. **Apply approvals.** For mechanical rules use `Edit` to mutate
|
||||
the markdown directly. For judgment rules, follow the option the
|
||||
user picked (file a ticket via `pql ticket new`, mark superseded
|
||||
by editing the record, etc.).
|
||||
|
||||
9. **Update the skip ledger and history.** Both files live at
|
||||
`decisions/.clean-house-state.md`, gitignored. The file uses
|
||||
per-run section headers so it doubles as run history:
|
||||
|
||||
```markdown
|
||||
# clean-house run history
|
||||
|
||||
## 2026-05-06T13:42Z
|
||||
fired: RULE-ANCHOR-DRIFT(1) RULE-SUNSET-WITHOUT-TICKET(1)
|
||||
applied: RULE-ANCHOR-DRIFT(1) RULE-SUNSET-WITHOUT-TICKET(1)
|
||||
skipped:
|
||||
- RULE-ANCHOR-DRIFT D-8:#q-1-markdown-mirror-for-tickets
|
||||
|
||||
## 2026-05-04T11:10Z
|
||||
fired: ...
|
||||
```
|
||||
|
||||
Each section starts with a UTC ISO-8601 timestamp. `fired`
|
||||
counts findings detected; `applied` counts findings the user
|
||||
acted on; `skipped` lists `<rule-id> <finding-id>` pairs.
|
||||
|
||||
**Promotion-candidate computation.** Scan the most recent N=7
|
||||
sections (or all sections if fewer). A rule is a promotion
|
||||
candidate if it `fired` in ≥ 5 of those sections. Surface the
|
||||
candidates in the summary; do not auto-promote.
|
||||
|
||||
10. **Emit the summary** (see Output below).
|
||||
|
||||
## Question phrasing
|
||||
|
||||
Always include the rule ID in the prompt so the user learns what's
|
||||
being checked.
|
||||
|
||||
**Mechanical (batched):**
|
||||
> RULE-ANCHOR-DRIFT: 12 cross-reference anchors point to headings that
|
||||
> no longer exist (renamed or deleted). Apply all 12 fixes? Options:
|
||||
> apply all / show diff first / skip this rule / stop asking, summarize
|
||||
> the rest.
|
||||
|
||||
**Judgment (individual):**
|
||||
> RULE-SUNSET-WITHOUT-TICKET: D-59 mentions "must track dugite-native
|
||||
> releases for security updates" but has no linked T. Options: file T
|
||||
> now / draft a T description first / mark as already covered (add note
|
||||
> to D) / skip / stop asking.
|
||||
|
||||
Phrasing rules:
|
||||
|
||||
- **Always include "stop asking, summarize the rest."** Sessions
|
||||
without bandwidth for full review need an escape. Forcing answers
|
||||
to every question kills the tool.
|
||||
- **Always include "skip."** Skipped findings go to the ledger; three
|
||||
consecutive skips of the same finding promote the next prompt.
|
||||
- **Mechanical batches; judgment doesn't.** "Apply all 12 fixes?" is
|
||||
one decision against a clear delta. Twelve individual prompts defeat
|
||||
the point. Conversely, judgment findings are each separate work —
|
||||
presenting them as a batch hides cost.
|
||||
|
||||
## What this skill does NOT do
|
||||
|
||||
- Does not file tickets without asking.
|
||||
- Does not rewrite D-record body prose, only metadata fields and
|
||||
cross-reference links.
|
||||
- Does not touch records under `legacy/`.
|
||||
- Does not run `pql decisions validate` *as a fix* — only as a
|
||||
precondition gate.
|
||||
- Does not promote rules to `pql decisions validate` automatically.
|
||||
Promotion is a human decision; this skill only flags candidates
|
||||
("RULE-X has fired in 5 of the last 7 runs — consider promoting").
|
||||
|
||||
## Non-interactive context
|
||||
|
||||
If invoked without an interactive `AskUserQuestion` surface (a CI run,
|
||||
a batch script), refuse to apply judgment fixes. Apply mechanical
|
||||
fixes only if the user explicitly says so via the trigger phrase
|
||||
("auto-apply mechanical, skip judgment"); otherwise emit the summary
|
||||
and exit without mutating files.
|
||||
|
||||
## Output
|
||||
|
||||
At the end, emit a summary block:
|
||||
|
||||
```
|
||||
clean-house — sweep complete
|
||||
────────────────────────────
|
||||
Files scanned: 11
|
||||
Records parsed: 62 D, 23 Q, 11 R
|
||||
Conventions: heading_level=3 backlink="Resolved → "
|
||||
Mechanical fixes: 14 applied, 0 deferred
|
||||
Judgment findings: 3 acted on, 2 deferred (in state file)
|
||||
Promotion candidates: RULE-ANCHOR-DRIFT (5/7 runs)
|
||||
Touched files: decisions/architecture.md, decisions/questions.md
|
||||
```
|
||||
|
||||
If this is the first run (no prior history) or there are fewer
|
||||
than 5 prior runs, `Promotion candidates:` reports
|
||||
`(none — N/7 runs of history)` instead of decorative empty content.
|
||||
|
||||
The summary names the touched files so the next commit message can be
|
||||
honest about what changed. The skill does not commit on its own.
|
||||
|
||||
## Versioning
|
||||
|
||||
clean-house ships embedded in the pql binary; its version is the
|
||||
pql version (`pql --version`). To see what changed and when, read
|
||||
`CHANGELOG.md` or `git log internal/skill/clean-house/` in the pql
|
||||
repo. Keep this file and `references/rules.md` consistent with
|
||||
each other when iterating — they're the contract.
|
||||
@@ -1,386 +0,0 @@
|
||||
# clean-house rule catalog
|
||||
|
||||
The rules clean-house runs and the reasoning behind each. New rules
|
||||
land here as their own entry. Catalog churn — additions, refinements,
|
||||
retirements — is tracked in pql's `CHANGELOG.md` and `git log
|
||||
internal/skill/clean-house/`. When a rule is retired, leave a
|
||||
**Retired** stub (ID + one-line reason + retirement commit) so the
|
||||
trail of "we used to check X" is recoverable from this file alone.
|
||||
|
||||
## Reading a rule
|
||||
|
||||
Each entry below has:
|
||||
|
||||
- **ID** — Stable identifier (e.g. `RULE-ANCHOR-DRIFT`). Used in
|
||||
AskUserQuestion prompts and skip-ledger entries so the user learns
|
||||
what's being checked.
|
||||
- **Category** — `mechanical` or `judgment`. Drives whether findings
|
||||
batch into one prompt or each get their own.
|
||||
- **Finding ID** — Format string for the per-finding stable
|
||||
identifier. Skip-ledger entries are written as `<rule-id>
|
||||
<finding-id> <ISO-date>`; subsequent runs use the same format to
|
||||
recognize "same finding skipped 3 runs in a row" and escalate.
|
||||
Use only stable inputs (record IDs, file paths, slug strings, body
|
||||
hashes) — never line numbers or timestamps.
|
||||
- **Detection** — Concrete steps to find violations. Names the
|
||||
pql command or file primitive used.
|
||||
- **Fix** — Mechanical: deterministic action. Judgment: the prompt
|
||||
option list and what each option does.
|
||||
- **Why** — One paragraph on the failure mode this rule guards
|
||||
against. Future-you reading the rule benefits from the reasoning,
|
||||
not just the check.
|
||||
|
||||
---
|
||||
|
||||
## RULE-ANCHOR-DRIFT
|
||||
|
||||
**Category:** mechanical
|
||||
|
||||
**Finding ID:** `<source-record>:<link-target>` (e.g.
|
||||
`D-8:#q-1-markdown-mirror-for-tickets` or
|
||||
`D-8:questions.md#q-1`).
|
||||
|
||||
**Detection:**
|
||||
|
||||
Anchor-only markdown links (`[text](#slug)`) resolve against the
|
||||
**source file's full heading set**, not the body of a single record
|
||||
— record-level headings (`### D-N: …`) live as siblings in the
|
||||
file and are valid link targets. The previous detection (which used
|
||||
`pql decisions read`'s body-only `headings` array) missed these
|
||||
and produced false positives.
|
||||
|
||||
Per source body:
|
||||
|
||||
1. Open the source body's file (`<vault>/<file_path>` from
|
||||
`pql decisions list`) and extract every ATX heading. Build a
|
||||
slug index for the file using the GFM convention (lowercase,
|
||||
hyphenate spaces, drop punctuation, disambiguate duplicates with
|
||||
`-1`/`-2`). Cache per file — every record in that file uses the
|
||||
same index.
|
||||
2. For each `[text](target)` link in the body:
|
||||
- Anchor-only (`#slug`): check `slug` against the **source file's**
|
||||
index. Missing → flag.
|
||||
- Cross-file (`path.md#slug`): resolve `path.md` relative to the
|
||||
source file's directory; check `slug` against that file's
|
||||
index. Missing file or missing slug → flag.
|
||||
|
||||
**Fix:**
|
||||
|
||||
If the slug exists in a sibling file's index (same directory) and
|
||||
the link is anchor-only, rewrite to `path.md#slug` (the canonical
|
||||
cross-file form). If the slug exists in the source file's index
|
||||
with edit-distance ≤ 2 from the link target, rewrite to the
|
||||
matched slug. Otherwise downgrade to judgment ("no auto-fix; the
|
||||
heading was removed, not renamed") with options: drop the link /
|
||||
point elsewhere / mark as intentionally dangling.
|
||||
|
||||
**Fix:**
|
||||
|
||||
If exactly one heading with a closely-matching slug exists (slug
|
||||
edit-distance ≤ 2 or substring match), rewrite the link to point at
|
||||
that heading. If no plausible match exists, downgrade the finding to
|
||||
judgment ("no auto-fix; the heading was removed, not renamed") and
|
||||
prompt the user with options: drop the link / point elsewhere / mark
|
||||
as intentionally dangling.
|
||||
|
||||
**Why:**
|
||||
|
||||
When a heading is renamed for clarity, every cross-reference pointing
|
||||
at the old slug silently breaks. The rendered docs still look fine —
|
||||
the link just goes nowhere. Without periodic sweeps these decay
|
||||
indefinitely; the cost of the sweep is small compared to the cost of
|
||||
a reader following a dead link and losing trust in the index.
|
||||
|
||||
---
|
||||
|
||||
## RULE-MISSING-Q-BACKLINK
|
||||
|
||||
**Category:** mechanical
|
||||
|
||||
**Finding ID:** `<d-id>:<q-id>` (e.g. `D-7:Q-2`). Order is always
|
||||
D-first regardless of which side is missing the backlink.
|
||||
|
||||
**Detection:**
|
||||
|
||||
For each D record body, find lines of the shape `Resolves: Q-N`
|
||||
(plain text, typically in the metadata block at the top of the
|
||||
record). For each Q-N referenced, fetch that Q record's body via
|
||||
`pql decisions read Q-N` and check whether it contains a line of the
|
||||
shape `Resolved → D-N` (or equivalent backlink phrasing — match the
|
||||
project's own convention; default pattern is `Resolved → D-N`).
|
||||
|
||||
Also check the reverse direction: any Q record claiming
|
||||
`Resolved → D-N` whose D-N body lacks `Resolves: Q-N`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Insert the missing backlink in-place using `Edit`:
|
||||
|
||||
- Missing on the D side: add `Resolves: Q-N` to the D's metadata
|
||||
block (typically right after `Domain:` / `Status:` lines).
|
||||
- Missing on the Q side: append `Resolved → D-N` to the Q's status
|
||||
line (or in the conventional position for that project).
|
||||
|
||||
**Why:**
|
||||
|
||||
Bidirectional Q↔D links are the navigation backbone of the DQR
|
||||
system. When one side drifts, search-by-decision finds nothing for
|
||||
that question and search-by-question doesn't surface its resolution.
|
||||
The asymmetric state usually arises from a hand-edit on one record
|
||||
that forgot to update the other; the fix is purely mechanical because
|
||||
the correct content is already determined by the existing pointer
|
||||
in the other direction.
|
||||
|
||||
---
|
||||
|
||||
## RULE-RECORD-SORT
|
||||
|
||||
**Category:** mechanical
|
||||
|
||||
**Finding ID:** `<file-path>` (e.g. `decisions/architecture.md`).
|
||||
The whole file is one finding — sort applies to the file as a
|
||||
unit.
|
||||
|
||||
**Detection:**
|
||||
|
||||
For each `decisions/*.md` file, read it directly (no pql) and find
|
||||
all top-level `## D-N` / `## Q-N` / `## R-N` headings in order. Strip
|
||||
the prefix, parse the numeric suffix, and check whether the sequence
|
||||
is monotonically ascending within each ID family (D, Q, R kept
|
||||
separate — files commonly mix families).
|
||||
|
||||
Only flag a file if it is **otherwise tidy** — currently no out-of-
|
||||
order amendments interleaved with new records. The heuristic: if the
|
||||
sort would touch fewer than three records out of position, apply it;
|
||||
if more, downgrade to judgment ("this file looks deliberately
|
||||
arranged — confirm before reordering"). Three is a soft threshold; if
|
||||
the file's last commit message contains `WIP` or `do-not-sort`, skip.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Reorder the records within the file so each ID family is ascending.
|
||||
Preserve everything else (headings between record blocks, any prose
|
||||
prelude/postlude). Use `Edit` with full block replacement, not in-
|
||||
place line shuffling — easier to verify the diff.
|
||||
|
||||
**Why:**
|
||||
|
||||
Records added at the bottom of a file are easy to write but hard to
|
||||
find. Sorted IDs let a reader find D-37 by jumping to the
|
||||
two-thirds mark of the file rather than scanning. The cost of the
|
||||
sort is one reordering pass; the benefit is paid back on every
|
||||
subsequent read.
|
||||
|
||||
---
|
||||
|
||||
## RULE-EOF-NORMALIZATION
|
||||
|
||||
**Category:** mechanical
|
||||
|
||||
**Finding ID:** `<file-path>` (one finding per file).
|
||||
|
||||
**Detection:**
|
||||
|
||||
For each `decisions/*.md` file (and any other markdown the skill
|
||||
touched during this run), read directly. Flag if:
|
||||
|
||||
- File does not end with exactly one `\n`.
|
||||
- Any line contains trailing whitespace before its `\n`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Trim trailing whitespace from each line. Ensure exactly one trailing
|
||||
newline at end of file. Use `Edit` only if a violation was found —
|
||||
this rule must not produce a no-op diff.
|
||||
|
||||
**Why:**
|
||||
|
||||
Editor and git config drift causes whitespace creep that's invisible
|
||||
in rendering but pollutes diffs (every record edit ends up touching
|
||||
unrelated lines). A periodic normalization keeps future diffs clean
|
||||
without forcing per-editor enforcement on every contributor.
|
||||
|
||||
---
|
||||
|
||||
## RULE-SUNSET-WITHOUT-TICKET
|
||||
|
||||
**Category:** judgment
|
||||
|
||||
**Finding ID:** `<record-id>:<phrase-hash-8>` where phrase-hash-8 is
|
||||
the first 8 hex chars of `sha256(<matched-phrase>)`. Lets the
|
||||
ledger distinguish two sunset phrases in the same record.
|
||||
|
||||
**Detection:**
|
||||
|
||||
For each D record body (via `pql decisions read <id>`), grep for
|
||||
sunset-shaped phrases. Default regex set:
|
||||
|
||||
```
|
||||
(?i)\b(delete|remove|sunset|kill[ -]?switch|tear[ -]?down) when\b
|
||||
(?i)\bmust (track|monitor|watch|follow)\b
|
||||
(?i)\brevisit (when|after|once)\b
|
||||
(?i)\b(deprecate|retire) (when|after|once)\b
|
||||
```
|
||||
|
||||
For each match, run `pql ticket list --decision <id>`. If no tickets
|
||||
exist, the D has work-shaped intent without a tracked T — flag.
|
||||
|
||||
**Fix (prompt options):**
|
||||
|
||||
- **File T now** — Run `pql ticket new task "<phrase>" --decision <id>`,
|
||||
then prompt the user for a description (or call `pql ticket refine
|
||||
write <T> --description ...` after creation).
|
||||
- **Draft a T description first** — Open an `AskUserQuestion` for the
|
||||
description, then file as above.
|
||||
- **Mark as already covered** — Add a note to the D body
|
||||
("Tracked under T-N") via `Edit`. Skill does not assert which T;
|
||||
user provides the ID.
|
||||
- **Skip** — Adds to the ledger.
|
||||
- **Stop asking** — Skip remaining sunset findings, summarize.
|
||||
|
||||
**Why:**
|
||||
|
||||
Sunset-shaped intent ("we'll handle X when Y happens") is the most
|
||||
common source of accumulated debt in a DQR system: the trigger
|
||||
condition arrives, nobody remembers the D, the work doesn't happen.
|
||||
Linking each sunset to a T is the simplest defense — a T is a
|
||||
backlog item that surfaces in `plan whatsnext` / `plan board`. The
|
||||
fix is judgment because the right action depends on whether the D's
|
||||
condition is still relevant, whether it's already covered, and what
|
||||
the right scope is for the resulting T.
|
||||
|
||||
---
|
||||
|
||||
## RULE-FILE-OVER-THRESHOLD
|
||||
|
||||
**Category:** judgment
|
||||
|
||||
**Finding ID:** `<file-path>` (one finding per file; threshold
|
||||
choice is judgment, not a per-line problem).
|
||||
|
||||
**Detection:**
|
||||
|
||||
For each `decisions/*.md` file, count lines (via `wc -l` or
|
||||
equivalent). Default threshold: **350 lines**. Configurable per
|
||||
project — read `decisions/.clean-house.yaml` if present, key
|
||||
`file_threshold`. Fall back to 350.
|
||||
|
||||
**Fix (prompt options):**
|
||||
|
||||
- **Split now (which axis?)** — Prompt for the split axis: by ID
|
||||
family (D/Q/R), by domain, by date range, custom. Then perform the
|
||||
split: create new file(s), move records, update any anchor links
|
||||
pointing into the moved records (run RULE-ANCHOR-DRIFT in apply
|
||||
mode against the affected files after the split).
|
||||
- **Accept and raise the threshold** — Update
|
||||
`decisions/.clean-house.yaml`'s `file_threshold` to the next
|
||||
reasonable round number above the current line count.
|
||||
- **Defer** — Skip until next run.
|
||||
|
||||
**Why:**
|
||||
|
||||
A single decisions file growing past ~350 lines is the point at which
|
||||
linear scanning starts to lose to grep, and where the cost of
|
||||
splitting (renaming anchors) is still small. Beyond ~600 lines the
|
||||
split cost compounds. The threshold is judgment because some projects
|
||||
deliberately keep one file per domain and accept the size; others
|
||||
split aggressively. The skill surfaces the question; it doesn't
|
||||
decide.
|
||||
|
||||
---
|
||||
|
||||
## RULE-DEAD-FILE-REFERENCE
|
||||
|
||||
**Category:** judgment
|
||||
|
||||
**Finding ID:** `<record-id>:<path-token>` (path token as written
|
||||
in the body, not resolved).
|
||||
|
||||
**Detection:**
|
||||
|
||||
For each D record body (via `pql decisions read <id>`), grep for
|
||||
path-shaped tokens. Default regex:
|
||||
|
||||
```
|
||||
\b([\w./-]+\.(md|go|py|sql|yaml|yml|toml))\b
|
||||
```
|
||||
|
||||
The first detection pass produced 6/6 false positives in
|
||||
real-world use; the regex is necessary but not sufficient. For
|
||||
each match, apply the filters below in order — if any matches,
|
||||
**skip without flagging**:
|
||||
|
||||
1. **Placeholder filter.** Token contains `T-NNN`, `D-NNN`,
|
||||
`Q-NNN`, `R-NNN`, `...`, `<`, `>`, or `*` — it's a pattern,
|
||||
not a real path.
|
||||
2. **Source-relative resolution.** Resolve the token against the
|
||||
source file's directory (`<vault>/<file_path>`'s dir). If
|
||||
`[ -e <resolved> ]`, the reference is live.
|
||||
3. **Repo-relative resolution.** If `[ -e <token> ]` from the
|
||||
repo root, the reference is live.
|
||||
4. **Basename-fallback.** Run `find <repo-root> -name <basename>`
|
||||
(where basename is the last path component). If exactly one
|
||||
match exists, treat the reference as live and emit a low-
|
||||
priority "consider rewriting to the absolute path" note (not a
|
||||
judgment finding). If multiple matches exist, do not flag —
|
||||
the token is too ambiguous.
|
||||
|
||||
Only after all four filters miss does the reference qualify as
|
||||
truly dead and warrant a judgment prompt.
|
||||
|
||||
**Fix (prompt options):**
|
||||
|
||||
- **Update reference** — Prompt for the new path; rewrite the
|
||||
reference via `Edit`. If the user types a path, validate it exists
|
||||
before applying.
|
||||
- **Mark superseded** — Add a note to the D ("This decision
|
||||
references files no longer present; the underlying constraint
|
||||
was retired by D-N") and prompt for the superseding D ID.
|
||||
- **Defer** — Skip until next run.
|
||||
|
||||
**Why:**
|
||||
|
||||
Decisions reference code paths to ground their reasoning in the
|
||||
codebase that motivated them. When the code moves or gets deleted,
|
||||
the D's reasoning becomes harder to verify. Dead refs are a signal
|
||||
that either the decision should be updated or the decision itself is
|
||||
no longer load-bearing — both are judgment calls the user needs to
|
||||
make.
|
||||
|
||||
---
|
||||
|
||||
## RULE-STALE-OPEN-Q
|
||||
|
||||
**Category:** judgment
|
||||
|
||||
**Finding ID:** `<q-id>` (one finding per Q-record; staleness
|
||||
threshold is global per run, so a Q is either stale or not).
|
||||
|
||||
**Detection:**
|
||||
|
||||
Run `pql decisions list --type question --status open`. For each
|
||||
returned record, check the `date` field. Default staleness threshold:
|
||||
**60 days** (configurable via `decisions/.clean-house.yaml` key
|
||||
`stale_open_q_days`). Flag any Q with `date` older than the
|
||||
threshold.
|
||||
|
||||
**Fix (prompt options):**
|
||||
|
||||
- **Still relevant** — Touch the Q's `date` field to today, optionally
|
||||
add a one-line "still open as of YYYY-MM-DD: <reason>" to the body.
|
||||
- **Nudge to load-bearing index** — If the project keeps a
|
||||
load-bearing Qs list (a curated index of unresolved questions
|
||||
affecting current work), prompt for inclusion and add the Q there.
|
||||
- **Mark withdrawn** — Change the Q's status to `withdrawn` (the DQR
|
||||
system's terminal state for "no longer worth answering"); the
|
||||
user supplies a one-line reason.
|
||||
- **Defer** — Skip until next run.
|
||||
|
||||
**Why:**
|
||||
|
||||
Open questions are useful when they're current; stale ones become
|
||||
ambient noise that drowns the signal of new questions. Most
|
||||
projects don't enforce explicit Q-lifecycle, so without a periodic
|
||||
prompt the open list grows forever. The right action depends on
|
||||
whether the question is still open in fact — not just in metadata —
|
||||
which only the user can confirm.
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
name: clide
|
||||
description: >
|
||||
Use when you are running inside the clide IDE and want to observe or drive
|
||||
its live UI — panes, editor, files, git, readers, toasts, layout — through
|
||||
the `clide` CLI, or to find out what commands clide exposes. clide is the
|
||||
IDE hosting this session; it puts `clide` on your PATH and a per-workspace
|
||||
socket in `CLIDE_SOCK`. Start with `clide capabilities` to enumerate the
|
||||
live tool surface. Triggers: "what can clide do", "drive the clide UI",
|
||||
"open this in clide", "show the user", "toast", or invoking /clide.
|
||||
clide is the IDE hosting this session; it puts `clide` on your PATH and a
|
||||
per-workspace socket in `CLIDE_SOCK`. Start with `clide capabilities` to
|
||||
enumerate the live tool surface, then observe or drive the UI — panes,
|
||||
editor, files, git, readers, toasts, layout — via `clide <subsystem> <verb>`.
|
||||
Triggers: "what can clide do", "drive the clide UI", "open this in clide",
|
||||
"show the user", "toast", or invoking /clide.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash
|
||||
---
|
||||
@@ -15,9 +14,10 @@ allowed-tools: Bash
|
||||
# Driving clide from the CLI
|
||||
|
||||
You are (often) running **inside clide** — a Flutter IDE that hosts this
|
||||
Claude session. It exposes its whole UI surface as a `clide <subsystem> <verb>`
|
||||
CLI on your PATH, talking to the running app over a per-workspace socket
|
||||
(`CLIDE_SOCK`). Every UI action the user can take has a CLI verb, and every
|
||||
Claude session. It puts `clide` on your PATH and exposes its whole UI surface
|
||||
as a `clide <subsystem> <verb>` CLI, talking to the running app over a
|
||||
per-workspace socket (`CLIDE_SOCK`). Every UI action the user can take has a
|
||||
CLI verb, and every
|
||||
verb's effect is observable — that is the parity contract (D-6). So you can
|
||||
*see what the user sees* and *show the user what you mean*.
|
||||
|
||||
@@ -53,9 +53,11 @@ one). `clide <subsystem>` with no verb, or an unknown command, prints usage.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Slots:** the layout has three content slots — `sidebar` (left), `workspace`
|
||||
(center, where Claude lives), `context` (right) — plus the bottom `statusbar`.
|
||||
Many verbs take a slot.
|
||||
- **Slots:** the layout has four content slots — `sidebar` (left), `workspace`
|
||||
(center, where Claude lives), `context` (right), and `dock` (bottom —
|
||||
Output/Problems panes, hidden by default; D-87) — plus the bottom
|
||||
`statusbar`. Many verbs take a slot. The live list is whatever
|
||||
`clide capabilities` reports.
|
||||
- **Honest failures:** a drive verb with no live GUI returns a `toolError`
|
||||
("no live UI to drive"), not a hang. JSON on stdout; exit code conveys
|
||||
ok/usage/tool error.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: commit
|
||||
description: Create a well-formatted git commit with staged changes
|
||||
---
|
||||
|
||||
# Git Commit
|
||||
|
||||
Create a well-formatted commit with staged changes following best practices.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status --porcelain` to check for changes
|
||||
2. If no staged changes, show unstaged files and ask what to stage
|
||||
3. Run `git diff --cached` to review staged changes
|
||||
4. Generate a commit message following Conventional Commits format:
|
||||
- `feat:` new feature
|
||||
- `fix:` bug fix
|
||||
- `docs:` documentation
|
||||
- `refactor:` code restructuring
|
||||
- `test:` adding tests
|
||||
- `chore:` maintenance
|
||||
5. Create commit with the message, adding Co-Authored-By trailer
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
|
||||
<body - what and why, not how>
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Warn about large commits (>500 lines changed)
|
||||
- Suggest splitting large changes into smaller commits
|
||||
- Never skip pre-commit hooks unless explicitly requested
|
||||
|
||||
## Changelog
|
||||
|
||||
After committing, update `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format:
|
||||
|
||||
1. Add entry under `[Unreleased]` section (create if not exists)
|
||||
2. Categorize changes:
|
||||
- `Added` - new features
|
||||
- `Changed` - changes to existing functionality
|
||||
- `Deprecated` - features marked for removal
|
||||
- `Removed` - removed features
|
||||
- `Fixed` - bug fixes
|
||||
- `Security` - security-related changes
|
||||
3. Write entries in imperative mood: "Add feature" not "Added feature"
|
||||
4. Reference issue numbers where applicable
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
name: d2-diagram
|
||||
description: >
|
||||
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
|
||||
user says "create a diagram", "draw architecture", "make a flowchart",
|
||||
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
|
||||
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
|
||||
Produces .d2 source files and renders them to PNG. Also use when asked
|
||||
to update, re-render, or batch render existing diagrams.
|
||||
---
|
||||
|
||||
# d2 Diagram Generation
|
||||
|
||||
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
|
||||
external dependencies beyond the d2 binary.
|
||||
|
||||
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
|
||||
|
||||
## Project Defaults
|
||||
|
||||
| Setting | Value | Override |
|
||||
|---------|-------|----------|
|
||||
| Theme | 200 (Dark Mauve) | `--theme N` |
|
||||
| Layout | dagre | `--layout elk` |
|
||||
| Padding | 100px | — |
|
||||
| Format | PNG | `--svg` |
|
||||
|
||||
## Output Convention
|
||||
|
||||
```
|
||||
docs/diagrams/
|
||||
architecture/ # System architecture, IPC, component layout
|
||||
data-flow/ # Sequence diagrams, data pipelines
|
||||
entity/ # ER diagrams, ECS component schemas
|
||||
state/ # State machines, behavior trees
|
||||
ui/ # UI navigation flow, screen transitions
|
||||
```
|
||||
|
||||
Both `.d2` source and `.png` output are tracked in git.
|
||||
|
||||
## Single Diagram Workflow
|
||||
|
||||
1. **Determine category** — architecture, data-flow, entity, state, or ui
|
||||
2. **Read template** — `references/diagram-templates.md` for the matching category
|
||||
3. **Read syntax** — `references/d2-syntax-guide.md` if unfamiliar with d2 syntax
|
||||
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
|
||||
5. **Validate** — `.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
|
||||
6. **Render** — `.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
|
||||
7. **Read SVG** — verify the output, present to user
|
||||
|
||||
### Script Usage
|
||||
|
||||
```bash
|
||||
# Render with project defaults
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Validate syntax only
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Auto-format source
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Sketch mode (hand-drawn look for drafts)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
|
||||
|
||||
# Light theme (for printable docs)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
|
||||
|
||||
# SVG output (if specifically needed)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
|
||||
```
|
||||
|
||||
## Batch Render
|
||||
|
||||
Re-render all diagrams after theme or style changes:
|
||||
|
||||
```bash
|
||||
# All diagrams
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh
|
||||
|
||||
# One category
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
|
||||
|
||||
# Preview what would render
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
|
||||
|
||||
# Force re-render everything
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
|
||||
```
|
||||
|
||||
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Variables for consistent styling
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#2a3040"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-board (layers)
|
||||
|
||||
```d2
|
||||
# Base diagram here
|
||||
|
||||
layers: {
|
||||
detailed: {
|
||||
# More detailed view
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sequence diagrams
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
client: Godot Client
|
||||
server: Rust Server
|
||||
|
||||
client -> server: TickRequest(delta)
|
||||
server -> client: WorldState(entities)
|
||||
```
|
||||
|
||||
### Imports
|
||||
|
||||
Split shared definitions into a separate file and import:
|
||||
|
||||
```d2
|
||||
...@shared-defs.d2
|
||||
```
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Qatux** — Architecture decision records, system overview diagrams, data
|
||||
schemas. Prefer architecture and entity templates.
|
||||
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
|
||||
system data flow. Prefer architecture and data-flow templates.
|
||||
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
|
||||
Prefer UI flow template.
|
||||
|
||||
## References
|
||||
|
||||
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
|
||||
containers, styling, variables). Read when unfamiliar with d2 syntax.
|
||||
- `references/diagram-templates.md` — Five category templates with complete
|
||||
d2 source examples. Read when starting a new diagram.
|
||||
@@ -1,212 +0,0 @@
|
||||
# D2 Language Quick Reference
|
||||
|
||||
## Nodes
|
||||
|
||||
```d2
|
||||
server # Implicit label from key
|
||||
server: Simulation Server # Explicit label
|
||||
server: Simulation Server { # With properties
|
||||
shape: hexagon
|
||||
style.fill: "#2d3436"
|
||||
}
|
||||
```
|
||||
|
||||
## Edges
|
||||
|
||||
```d2
|
||||
a -> b # Directed
|
||||
a <- b # Reverse directed
|
||||
a <-> b # Bidirectional
|
||||
a -- b # Undirected
|
||||
a -> b: "label" # Labeled edge
|
||||
a -> b -> c # Chained
|
||||
```
|
||||
|
||||
## Containers (nesting)
|
||||
|
||||
```d2
|
||||
infrastructure: {
|
||||
server: Simulation Server
|
||||
database: State Store {
|
||||
shape: cylinder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shapes
|
||||
|
||||
| Shape | Use for |
|
||||
|-------|---------|
|
||||
| `rectangle` | Default. Components, modules, generic. |
|
||||
| `hexagon` | Systems, services, major components. |
|
||||
| `cylinder` | Databases, storage, persistent state. |
|
||||
| `diamond` | Decisions, conditions, branch points. |
|
||||
| `oval` / `circle` | Start/end states, events. |
|
||||
| `cloud` | External systems, networks. |
|
||||
| `person` | Actors, users, NPCs. |
|
||||
| `queue` | Message queues, buffers. |
|
||||
| `page` | Documents, files. |
|
||||
| `package` | Packages, modules, crates. |
|
||||
| `sql_table` | Database tables, ECS component schemas. |
|
||||
| `class` | Class diagrams, ECS system definitions. |
|
||||
| `code` | Code blocks (set `language` property). |
|
||||
| `markdown` | Rich text blocks. |
|
||||
|
||||
## SQL Tables
|
||||
|
||||
```d2
|
||||
entity: {
|
||||
shape: sql_table
|
||||
id: u64 {constraint: primary_key}
|
||||
name: String
|
||||
position: Vec2
|
||||
faction_id: u64 {constraint: foreign_key}
|
||||
}
|
||||
```
|
||||
|
||||
## Class Diagrams
|
||||
|
||||
```d2
|
||||
perception_system: {
|
||||
shape: class
|
||||
+run(world: &mut World)
|
||||
-calculate_los(entity: Entity): HashSet<Entity>
|
||||
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
|
||||
}
|
||||
```
|
||||
|
||||
## Sequence Diagrams
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
client: Godot Client
|
||||
server: Rust Server
|
||||
|
||||
client -> server: TickRequest(delta)
|
||||
server -> server: run ECS systems
|
||||
server -> client: WorldState(entities)
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
```d2
|
||||
node: Label {
|
||||
style: {
|
||||
fill: "#2d3436"
|
||||
stroke: "#333340"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5 # Dashed line
|
||||
opacity: 0.8
|
||||
font-size: 14
|
||||
font-color: "#c8d0e0"
|
||||
bold: true
|
||||
italic: false
|
||||
border-radius: 4
|
||||
shadow: true
|
||||
3d: true # Rectangles only
|
||||
multiple: true # Stacked appearance
|
||||
double-border: true # Rectangles/ovals only
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edge styling
|
||||
|
||||
```d2
|
||||
a -> b: {
|
||||
style: {
|
||||
stroke: "#c8d8f0"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5
|
||||
opacity: 0.8
|
||||
animated: true # Animated flow
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#1a1e24"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
|
||||
node: {
|
||||
style.fill: ${color-bg}
|
||||
style.stroke: ${color-stroke}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
```
|
||||
|
||||
## Direction
|
||||
|
||||
```d2
|
||||
direction: right # left-to-right (default for dagre)
|
||||
direction: down # top-to-bottom
|
||||
direction: up
|
||||
direction: left
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```d2
|
||||
...@shared-defs.d2 # Spread import (inline all definitions)
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
```d2
|
||||
node: Label {
|
||||
icon: https://icons.terrastruct.com/essentials/time.svg
|
||||
}
|
||||
```
|
||||
|
||||
## Layers (multi-board)
|
||||
|
||||
```d2
|
||||
# Base diagram content here
|
||||
|
||||
layers: {
|
||||
detailed: {
|
||||
# More detailed view
|
||||
}
|
||||
simplified: {
|
||||
# Simplified overview
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scenarios (animated transitions)
|
||||
|
||||
```d2
|
||||
# Base state
|
||||
|
||||
scenarios: {
|
||||
alert: {
|
||||
# Changes from base for alert state
|
||||
}
|
||||
combat: {
|
||||
# Changes from base for combat state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
```d2
|
||||
# This is a comment
|
||||
node: Label # Inline comment
|
||||
```
|
||||
|
||||
## Project Colors (from visual-grammar-v01.md)
|
||||
|
||||
| Constant | Hex | Usage |
|
||||
|----------|-----|-------|
|
||||
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
|
||||
| Zone 1 wall | `#2a3040` | Component fill |
|
||||
| Outline standard | `#333340` | Borders, strokes |
|
||||
| Insert chrome | `#c8d0e0` | Text, labels |
|
||||
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
|
||||
@@ -1,251 +0,0 @@
|
||||
# Diagram Templates
|
||||
|
||||
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Diagram
|
||||
|
||||
System components, relationships, communication channels.
|
||||
|
||||
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
|
||||
|
||||
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#2a3040"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
|
||||
direction: right
|
||||
|
||||
client: Godot Client {
|
||||
shape: hexagon
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
rendering: Rendering {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
ui: UI Layer {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
bridge: IPC Bridge {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: ${color-accent}
|
||||
}
|
||||
}
|
||||
|
||||
server: Rust Server {
|
||||
shape: hexagon
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
ecs: bevy_ecs {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
perception: Perception {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
bridge: IPC Bridge {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: ${color-accent}
|
||||
}
|
||||
}
|
||||
|
||||
client.bridge -> server.bridge: "stdin/stdout" {
|
||||
style.stroke: ${color-accent}
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Entity Relationship
|
||||
|
||||
Data schemas, ECS components, knowledge graph structure.
|
||||
|
||||
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
|
||||
|
||||
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
|
||||
|
||||
```d2
|
||||
entity: Entity {
|
||||
shape: sql_table
|
||||
id: u64 {constraint: primary_key}
|
||||
name: String
|
||||
faction_id: u64 {constraint: foreign_key}
|
||||
}
|
||||
|
||||
position: Position {
|
||||
shape: sql_table
|
||||
entity_id: u64 {constraint: foreign_key}
|
||||
x: f32
|
||||
y: f32
|
||||
chunk_id: u32
|
||||
}
|
||||
|
||||
knowledge: KnowledgeEntry {
|
||||
shape: sql_table
|
||||
observer_id: u64 {constraint: foreign_key}
|
||||
subject_id: u64 {constraint: foreign_key}
|
||||
fact_type: FactType
|
||||
confidence: f32
|
||||
last_seen_tick: u64
|
||||
}
|
||||
|
||||
entity.id -> position.entity_id
|
||||
entity.id -> knowledge.observer_id
|
||||
entity.id -> knowledge.subject_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Sequence / Data Flow
|
||||
|
||||
Ordered interactions between systems over time.
|
||||
|
||||
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
|
||||
|
||||
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
|
||||
client: Godot Client
|
||||
bridge: IPC Bridge
|
||||
server: Rust Server
|
||||
ecs: ECS Systems
|
||||
|
||||
client -> bridge: TickRequest(delta, input)
|
||||
bridge -> server: deserialize + dispatch
|
||||
server -> ecs: run_systems(delta)
|
||||
ecs -> ecs: perception, AI, physics
|
||||
ecs -> server: collect WorldState
|
||||
server -> bridge: serialize WorldState
|
||||
bridge -> client: WorldState(entities, events)
|
||||
client -> client: update rendering
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. State Machine
|
||||
|
||||
Entity states, transitions, conditions.
|
||||
|
||||
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
|
||||
|
||||
**Agents:** Tyre (behavior system design), Qatux (state documentation).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-state: "#2a3040"
|
||||
color-text: "#c8d0e0"
|
||||
color-edge: "#c8d8f0"
|
||||
color-decision: "#333340"
|
||||
}
|
||||
|
||||
idle: Idle {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
alert: Alert {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
investigate: Investigate {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
combat: Combat {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: "#f0b840"
|
||||
}
|
||||
|
||||
flee: Flee {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
|
||||
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
|
||||
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
|
||||
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
|
||||
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Flow
|
||||
|
||||
Screen navigation, component hierarchy, interaction paths.
|
||||
|
||||
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
|
||||
|
||||
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-screen: "#1a1e24"
|
||||
color-panel: "#2a3040"
|
||||
color-text: "#c8d0e0"
|
||||
color-nav: "#c8d8f0"
|
||||
}
|
||||
|
||||
gameplay: Gameplay {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
hud: HUD {
|
||||
style.fill: ${color-panel}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
minimap: Minimap
|
||||
monologue: Monologue Panel
|
||||
insert_display: Insert Display
|
||||
}
|
||||
|
||||
world: World View {
|
||||
style.fill: ${color-panel}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
}
|
||||
|
||||
pause: Pause Menu {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
inventory: Inventory
|
||||
journal: Journal
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
dialogue: Dialogue Mode {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
speaker: Speaker Panel
|
||||
responses: Response List
|
||||
}
|
||||
|
||||
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
|
||||
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
|
||||
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||
```
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
RENDER="$SCRIPT_DIR/d2-render.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [directory] [options]
|
||||
|
||||
Batch render all .d2 files in a directory. Skips files whose PNG is
|
||||
newer than the source unless --force is used.
|
||||
|
||||
Options:
|
||||
--dry-run List files that would be rendered
|
||||
--force Re-render even if SVG is up to date
|
||||
--theme N Override theme for all files
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # All in docs/diagrams/
|
||||
$(basename "$0") docs/diagrams/architecture/ # One category
|
||||
$(basename "$0") --dry-run # Preview
|
||||
$(basename "$0") --force # Re-render everything
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
DIR="$REPO_ROOT/docs/diagrams"
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
EXTRA_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
|
||||
--help|-h) usage ;;
|
||||
*)
|
||||
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
|
||||
DIR="$1"
|
||||
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
|
||||
else
|
||||
echo "Unknown option or directory: $1" >&2; exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
|
||||
|
||||
RENDERED=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
while IFS= read -r -d '' d2_file; do
|
||||
png_file="${d2_file%.d2}.png"
|
||||
|
||||
# Skip if PNG is newer than source (unless --force)
|
||||
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
rel_path="${d2_file#"$REPO_ROOT/"}"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Would render: $rel_path"
|
||||
RENDERED=$((RENDERED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
|
||||
RENDERED=$((RENDERED + 1))
|
||||
else
|
||||
echo "FAILED: $rel_path" >&2
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
|
||||
|
||||
echo ""
|
||||
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
D2="/home/linuxbrew/.linuxbrew/bin/d2"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
|
||||
DEFAULT_THEME=200
|
||||
DEFAULT_LAYOUT="dagre"
|
||||
DEFAULT_PAD=100
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
|
||||
|
||||
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
|
||||
|
||||
Commands:
|
||||
validate <file> Check syntax without rendering
|
||||
fmt <file> Auto-format in place
|
||||
|
||||
Options:
|
||||
--theme N Override theme (default: $DEFAULT_THEME)
|
||||
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
|
||||
--sketch Enable hand-drawn sketch mode
|
||||
--output PATH Override output path (default: input with .png extension)
|
||||
--svg Render to SVG instead of PNG
|
||||
|
||||
Examples:
|
||||
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
|
||||
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
|
||||
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
# Parse subcommand
|
||||
SUBCMD=""
|
||||
case "$1" in
|
||||
validate|fmt)
|
||||
SUBCMD="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
INPUT="$1"
|
||||
shift
|
||||
|
||||
# Resolve to absolute path
|
||||
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
|
||||
|
||||
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
|
||||
|
||||
# Handle subcommands
|
||||
if [[ -n "$SUBCMD" ]]; then
|
||||
"$D2" "$SUBCMD" "$INPUT"
|
||||
echo "OK: $SUBCMD $INPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse render options
|
||||
THEME="$DEFAULT_THEME"
|
||||
LAYOUT="$DEFAULT_LAYOUT"
|
||||
SKETCH=""
|
||||
OUTPUT=""
|
||||
FORMAT="png"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--theme) THEME="$2"; shift 2 ;;
|
||||
--layout) LAYOUT="$2"; shift 2 ;;
|
||||
--sketch) SKETCH="-s"; shift ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
--svg) FORMAT="svg"; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Derive output path
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
OUTPUT="${INPUT%.d2}.$FORMAT"
|
||||
fi
|
||||
|
||||
# Render
|
||||
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
|
||||
|
||||
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
|
||||
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
|
||||
@@ -1,222 +0,0 @@
|
||||
---
|
||||
name: frame0-wireframe
|
||||
description: >
|
||||
Create and export UI wireframes using Frame0 (local desktop wireframing
|
||||
app with HTTP API). Use when the user says "create wireframe", "wireframe
|
||||
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
|
||||
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
|
||||
Wireframes are authored as local JSON files (source of truth) and synced
|
||||
to Frame0 for rendering and export. Requires Frame0 to be running locally.
|
||||
---
|
||||
|
||||
# Frame0 Wireframe Generation
|
||||
|
||||
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
|
||||
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
|
||||
|
||||
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
|
||||
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
|
||||
the user explicitly says they have made edits in Frame0 and want to import
|
||||
them. The pull workflow exists for that case only — do not use it proactively.
|
||||
|
||||
**Prerequisite:** Frame0 desktop app must be running. If not available,
|
||||
stop and inform the user. Point to `references/setup-guide.md`.
|
||||
|
||||
## Health Check
|
||||
|
||||
Always check first:
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Wireframing is a **design conversation, not a batch job.** Produce **one screen
|
||||
at a time**, show it, and **stop for the user's reaction** before authoring the
|
||||
next. Each shown screen is a checkpoint — never fan out a whole set of
|
||||
wireframes unprompted, even if the user described several screens up front.
|
||||
|
||||
1. **Health check** — verify Frame0 is running
|
||||
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`.
|
||||
Author **one** screen.
|
||||
3. **Push to Frame0** — `frame0-sync.py push <file.json>`
|
||||
4. **Export PNG** — `frame0-sync.py export <file.json> <output.png>`
|
||||
5. **Show it inline** — surface the exported PNG in the conversation so the
|
||||
user sees the result without opening Frame0. Always do this after an export,
|
||||
and again whenever you re-export or the user asks to see it.
|
||||
- **Inside clide** (the IDE hosting this session — `CLIDE_SOCK` is set):
|
||||
render it as a native image card in the live Claude pane with
|
||||
`clide image show <path> [--caption "…"]` (T-249). This is the *proper*
|
||||
way to put an image in the clide conversation — a bare `Read` only shows
|
||||
it in the transcript, not in the UI the user is looking at.
|
||||
- **Otherwise**: `Read` the PNG so it renders inline in the transcript.
|
||||
6. **STOP and wait for approval** — after showing a screen, pause and ask the
|
||||
user for feedback. Do **not** author, push, or export the next screen until
|
||||
they give an explicit go-ahead. Iterate on the current screen until they're
|
||||
happy, then proceed to the next one — looping back to step 2 for each.
|
||||
7. **Clean up** — delete test/scratch pages from Frame0 when done
|
||||
|
||||
> **Multi-screen requests are still one-at-a-time.** If the user asks for
|
||||
> several screens, treat it as a queue: build the first, show it, get approval,
|
||||
> then move on. The only exception is an explicit, unambiguous instruction to
|
||||
> generate a batch without stopping (and even then prefer the batch-export
|
||||
> dry-run + approval flow below).
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
|
||||
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
|
||||
|
||||
## Wireframe JSON Format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dialogue Box",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||
"fillColor": "#1a1e24",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"speaker": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 520,
|
||||
"text": "LERA KONSTANTIN",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontSize": 16
|
||||
},
|
||||
"btn-ask": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
}
|
||||
},
|
||||
"connectors": {
|
||||
"flow-1": {
|
||||
"tailId": "panel",
|
||||
"headId": "btn-ask",
|
||||
"strokeColor": "#c8d8f0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
|
||||
- **`parent`** references another local shape ID for nesting
|
||||
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
|
||||
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
|
||||
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
|
||||
theme tokens for colors). The sync script handles the mapping transparently.
|
||||
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
|
||||
|
||||
### Sync commands
|
||||
|
||||
```bash
|
||||
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
|
||||
|
||||
# Push local JSON to Frame0 (clears page, recreates all shapes)
|
||||
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
|
||||
|
||||
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
|
||||
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
|
||||
|
||||
# Push + export as PNG in one step
|
||||
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
|
||||
docs/design/wireframes/dialogue/dialogue-box.png
|
||||
```
|
||||
|
||||
### Batch export
|
||||
|
||||
Use this when exporting multiple wireframes. It runs as a single Bash call,
|
||||
avoiding repeated permission prompts.
|
||||
|
||||
```bash
|
||||
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
|
||||
|
||||
# Dry run first — shows full manifest, no Frame0 calls
|
||||
$BATCH --dry-run
|
||||
|
||||
# Export everything (skips PNGs already newer than their JSON)
|
||||
$BATCH
|
||||
|
||||
# Export one category only
|
||||
$BATCH --category dialogue
|
||||
|
||||
# Force re-export of everything
|
||||
$BATCH --force
|
||||
```
|
||||
|
||||
**Always dry-run first, then get approval before running the live export.**
|
||||
|
||||
## Low-Level Commands
|
||||
|
||||
For ad-hoc operations or exec calls not covered by sync:
|
||||
|
||||
```bash
|
||||
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
|
||||
$CMD health
|
||||
$CMD list-pages
|
||||
$CMD current-page
|
||||
$CMD get-page <page-id>
|
||||
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||
$CMD create-connector <tail-id> <head-id>
|
||||
$CMD move <shape-id> <dx> <dy>
|
||||
$CMD export --format image/png
|
||||
$CMD exec "view:fit-to-screen" '{}'
|
||||
```
|
||||
|
||||
If you find yourself using `exec` for the same command repeatedly, flag it as
|
||||
a candidate for a proper wrapper in `frame0-cmd.sh`.
|
||||
|
||||
## Project Styling Defaults
|
||||
|
||||
Colors from `docs/design/visual-grammar-v01.md`:
|
||||
|
||||
| Role | Hex | Frame0 token |
|
||||
|------|-----|-------------|
|
||||
| Background | `#1a1e24` | `$sage3` |
|
||||
| Stroke | `#333340` | `$slate6` |
|
||||
| Fill | `#2a3040` | `$slate5` |
|
||||
| Text | `#c8d0e0` | `$mint12` |
|
||||
| Accent | `#c8d8f0` | `$blue12` |
|
||||
|
||||
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
|
||||
|
||||
## Output Convention
|
||||
|
||||
```
|
||||
docs/design/wireframes/
|
||||
hud/ # HUD layout wireframes
|
||||
menus/ # Menu screen wireframes
|
||||
dialogue/ # Dialogue box wireframes
|
||||
insert/ # Neural insert wireframes
|
||||
```
|
||||
|
||||
Both `.json` source and `.png` exports are tracked in git.
|
||||
`.idmap.json` mapping files are gitignored.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
|
||||
visual consistency. Uses all component library patterns.
|
||||
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
|
||||
- **Qatux** — Export wireframes for UI decision records and documentation.
|
||||
|
||||
## References
|
||||
|
||||
- `references/component-library.md` — Pre-built JSON wireframe templates
|
||||
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
|
||||
- `references/api-reference.md` — Frame0 HTTP API command reference and
|
||||
type mappings. Read for low-level control.
|
||||
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
|
||||
@@ -1,241 +0,0 @@
|
||||
# Frame0 HTTP API Reference
|
||||
|
||||
Frame0 exposes a local HTTP API when the desktop app is running.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST http://localhost:{port}/execute_command
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
|
||||
|
||||
## Request / Response
|
||||
|
||||
```json
|
||||
{"command": "namespace:action", "args": { ... }}
|
||||
```
|
||||
|
||||
```json
|
||||
{"success": true, "data": { ... }}
|
||||
{"success": false, "error": "description"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Mapping
|
||||
|
||||
Frame0 uses different type names for create vs get:
|
||||
|
||||
| Create API (`type`) | Get API (internal) | Description |
|
||||
|--------------------|--------------------|-------------|
|
||||
| `Rectangle` | `Box` | Rectangle with optional corners |
|
||||
| `Ellipse` | `Ellipse` | Circle/ellipse |
|
||||
| `Text` | `Text` | Text label |
|
||||
| `Line` | `Line` | Line/polyline |
|
||||
| `Frame` | `Frame` | Container from library |
|
||||
| `Freehand` | `Freehand` | Freehand drawing |
|
||||
| `Highlighter` | `Highlighter` | Highlighter stroke |
|
||||
|
||||
The sync script handles this mapping transparently.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
|
||||
|
||||
| Hex | Token | Role |
|
||||
|-----|-------|------|
|
||||
| `#1a1e24` | `$sage3` | Background |
|
||||
| `#2a3040` | `$slate5` | Fill |
|
||||
| `#333340` | `$slate6` | Stroke |
|
||||
| `#c8d0e0` | `$mint12` | Text |
|
||||
| `#c8d8f0` | `$blue12` | Accent |
|
||||
|
||||
Both hex and token strings work in the API. Tokens are preserved on round-trip.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### shape:create-shape
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-shape",
|
||||
"args": {
|
||||
"type": "Rectangle",
|
||||
"shapeProps": {
|
||||
"name": "my-button",
|
||||
"left": 100, "top": 200, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"parentId": "optional-parent-shape-id",
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns: shape ID (string).
|
||||
|
||||
### shape:get-shape
|
||||
|
||||
```json
|
||||
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
|
||||
```
|
||||
|
||||
### shape:update-shape
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:update-shape",
|
||||
"args": {
|
||||
"shapeId": "id",
|
||||
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:move
|
||||
|
||||
```json
|
||||
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
|
||||
```
|
||||
|
||||
### shape:create-connector
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-connector",
|
||||
"args": {
|
||||
"tailId": "source-id",
|
||||
"headId": "target-id",
|
||||
"shapeProps": {"strokeColor": "#c8d8f0"},
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:create-icon
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-icon",
|
||||
"args": {
|
||||
"iconName": "search",
|
||||
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:get-available-icons
|
||||
|
||||
```json
|
||||
{"command": "shape:get-available-icons", "args": {}}
|
||||
```
|
||||
|
||||
### shape:group / shape:ungroup
|
||||
|
||||
```json
|
||||
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
|
||||
```
|
||||
|
||||
### edit:delete / edit:duplicate
|
||||
|
||||
```json
|
||||
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
|
||||
```
|
||||
|
||||
### page:add
|
||||
|
||||
```json
|
||||
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
|
||||
```
|
||||
|
||||
Returns: `{id, type, name}`.
|
||||
|
||||
### page:get
|
||||
|
||||
```json
|
||||
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
|
||||
```
|
||||
|
||||
### page:get-current-page
|
||||
|
||||
```json
|
||||
{"command": "page:get-current-page", "args": {}}
|
||||
```
|
||||
|
||||
Returns: page ID string.
|
||||
|
||||
### page:set-current-page
|
||||
|
||||
```json
|
||||
{"command": "page:set-current-page", "args": {"pageId": "id"}}
|
||||
```
|
||||
|
||||
### doc:get (list all pages)
|
||||
|
||||
```json
|
||||
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
|
||||
```
|
||||
|
||||
### page:delete
|
||||
|
||||
```json
|
||||
{"command": "page:delete", "args": {"pageId": "id"}}
|
||||
```
|
||||
|
||||
### file:export-image
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "file:export-image",
|
||||
"args": {
|
||||
"pageId": "optional-page-id",
|
||||
"format": "image/png",
|
||||
"fillBackground": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
|
||||
Returns: base64-encoded image data.
|
||||
|
||||
### view:fit-to-screen
|
||||
|
||||
```json
|
||||
{"command": "view:fit-to-screen", "args": {}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shape Properties
|
||||
|
||||
| Property | Type | Notes |
|
||||
|----------|------|-------|
|
||||
| `name` | string | Shape identifier/label |
|
||||
| `left` | number | X position (origin: top-left) |
|
||||
| `top` | number | Y position |
|
||||
| `width` | number | Width in pixels |
|
||||
| `height` | number | Height in pixels |
|
||||
| `fillColor` | string | Hex or `$token` |
|
||||
| `strokeColor` | string | Hex or `$token` |
|
||||
| `strokeWidth` | number | Border width |
|
||||
| `fontColor` | string | Text color (hex or `$token`) |
|
||||
| `fontSize` | number | Font size in pixels |
|
||||
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
|
||||
| `text` | string | Text content |
|
||||
| `wordWrap` | boolean | Enable word wrapping |
|
||||
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
|
||||
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
|
||||
| `constraints` | array | Auto-sizing constraints |
|
||||
| `horzAlign` | string | Horizontal text alignment |
|
||||
| `vertAlign` | string | Vertical text alignment |
|
||||
| `fillStyle` | string | Fill style (`none` for transparent) |
|
||||
| `path` | array | Coordinate pairs for lines |
|
||||
@@ -1,467 +0,0 @@
|
||||
# Component Library
|
||||
|
||||
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
|
||||
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
|
||||
|
||||
**Viewport:** 1140x780 (Godot project settings)
|
||||
**Grid unit:** 8px
|
||||
**Min touch target:** 36px height
|
||||
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
|
||||
|
||||
---
|
||||
|
||||
## 1. HUD Layout
|
||||
|
||||
Main gameplay overlay. Minimap top-right, monologue bottom-center,
|
||||
insert display bottom-left, action hints bottom-right.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "HUD Layout",
|
||||
"shapes": {
|
||||
"minimap": {
|
||||
"type": "Rectangle",
|
||||
"left": 880, "top": 20, "width": 240, "height": 240,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"minimap-label": {
|
||||
"type": "Text",
|
||||
"parent": "minimap",
|
||||
"left": 890, "top": 30,
|
||||
"text": "Minimap",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"monologue": {
|
||||
"type": "Rectangle",
|
||||
"left": 300, "top": 680, "width": 520, "height": 80,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"monologue-text": {
|
||||
"type": "Text",
|
||||
"parent": "monologue",
|
||||
"left": 310, "top": 700, "width": 500,
|
||||
"text": "Internal monologue text appears here...",
|
||||
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
|
||||
},
|
||||
"insert": {
|
||||
"type": "Rectangle",
|
||||
"left": 20, "top": 600, "width": 260, "height": 160,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"insert-label": {
|
||||
"type": "Text",
|
||||
"parent": "insert",
|
||||
"left": 30, "top": 620,
|
||||
"text": "Neural Insert Data",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"hints": {
|
||||
"type": "Rectangle",
|
||||
"left": 880, "top": 700, "width": 240, "height": 60,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"hints-label": {
|
||||
"type": "Text",
|
||||
"parent": "hints",
|
||||
"left": 890, "top": 720,
|
||||
"text": "[E] Interact [TAB] Insert",
|
||||
"fontColor": "#c8d0e0", "fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Dialogue Box
|
||||
|
||||
Speaker panel with response options. Anchored bottom-center during dialogue mode.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dialogue Box",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"speaker": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 520,
|
||||
"text": "LERA KONSTANTIN",
|
||||
"fontColor": "#c8d0e0", "fontSize": 16
|
||||
},
|
||||
"text-area": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 550, "width": 760, "height": 100,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"dialogue-text": {
|
||||
"type": "Text",
|
||||
"parent": "text-area",
|
||||
"left": 200, "top": 560, "width": 740,
|
||||
"text": "You look like you could use a drink. First time on the station?",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
},
|
||||
"btn-option1": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-option1-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-option1",
|
||||
"left": 200, "top": 674,
|
||||
"text": "[1] Ask about the station",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"btn-option2": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 710, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-option2-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-option2",
|
||||
"left": 200, "top": 714,
|
||||
"text": "[2] Ask about recent events",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"btn-leave": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 580, "top": 670, "width": 180, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-leave-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-leave",
|
||||
"left": 590, "top": 674,
|
||||
"text": "[3] Leave",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Menu Screen
|
||||
|
||||
Full-screen menu with sidebar navigation and content area.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Pause Menu",
|
||||
"shapes": {
|
||||
"bg": {
|
||||
"type": "Rectangle",
|
||||
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||
"fillColor": "#1a1e24"
|
||||
},
|
||||
"nav": {
|
||||
"type": "Rectangle",
|
||||
"parent": "bg",
|
||||
"left": 20, "top": 20, "width": 200, "height": 740,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-inventory": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 40, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-inventory-label": {
|
||||
"type": "Text", "parent": "btn-inventory",
|
||||
"left": 40, "top": 48, "text": "Inventory",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-journal": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 86, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-journal-label": {
|
||||
"type": "Text", "parent": "btn-journal",
|
||||
"left": 40, "top": 94, "text": "Journal",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-map": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 132, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-map-label": {
|
||||
"type": "Text", "parent": "btn-map",
|
||||
"left": 40, "top": 140, "text": "Map",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-settings": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 178, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-settings-label": {
|
||||
"type": "Text", "parent": "btn-settings",
|
||||
"left": 40, "top": 186, "text": "Settings",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-resume": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 720, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-resume-label": {
|
||||
"type": "Text", "parent": "btn-resume",
|
||||
"left": 40, "top": 728, "text": "Resume",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"content": {
|
||||
"type": "Rectangle",
|
||||
"parent": "bg",
|
||||
"left": 240, "top": 20, "width": 880, "height": 740,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"content-label": {
|
||||
"type": "Text", "parent": "content",
|
||||
"left": 260, "top": 40,
|
||||
"text": "Content area",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Modal Dialog
|
||||
|
||||
Centered overlay for confirmations, alerts, choices.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Modal Dialog",
|
||||
"shapes": {
|
||||
"overlay": {
|
||||
"type": "Rectangle",
|
||||
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||
"fillColor": "#0a0c10"
|
||||
},
|
||||
"modal": {
|
||||
"type": "Rectangle",
|
||||
"parent": "overlay",
|
||||
"left": 320, "top": 240, "width": 500, "height": 300,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"title": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 260,
|
||||
"text": "Confirm Action",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"divider": {
|
||||
"type": "Line", "parent": "modal",
|
||||
"left": 340, "top": 290, "width": 460, "height": 0,
|
||||
"strokeColor": "#333340"
|
||||
},
|
||||
"body-1": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 310,
|
||||
"text": "Are you sure you want to proceed?",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"body-2": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 340,
|
||||
"text": "This action cannot be undone.",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"btn-cancel": {
|
||||
"type": "Rectangle", "parent": "modal",
|
||||
"left": 480, "top": 480, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-cancel-label": {
|
||||
"type": "Text", "parent": "btn-cancel",
|
||||
"left": 510, "top": 488,
|
||||
"text": "Cancel",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-confirm": {
|
||||
"type": "Rectangle", "parent": "modal",
|
||||
"left": 620, "top": 480, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-confirm-label": {
|
||||
"type": "Text", "parent": "btn-confirm",
|
||||
"left": 645, "top": 488,
|
||||
"text": "Confirm",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. List View
|
||||
|
||||
Scrollable list with item selection and detail panel.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "List View",
|
||||
"shapes": {
|
||||
"list-panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 20, "top": 20, "width": 400, "height": 740,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-1": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 30, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-1-label": {
|
||||
"type": "Text", "parent": "item-1",
|
||||
"left": 40, "top": 38, "text": "Item Alpha",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"item-2": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 80, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-2-label": {
|
||||
"type": "Text", "parent": "item-2",
|
||||
"left": 40, "top": 88, "text": "Item Beta",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"item-3": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 130, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-3-label": {
|
||||
"type": "Text", "parent": "item-3",
|
||||
"left": 40, "top": 138, "text": "Item Gamma",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"detail-panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 440, "top": 20, "width": 680, "height": 740,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail-title": {
|
||||
"type": "Text", "parent": "detail-panel",
|
||||
"left": 460, "top": 40,
|
||||
"text": "Item Alpha",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"detail-body": {
|
||||
"type": "Text", "parent": "detail-panel",
|
||||
"left": 460, "top": 80, "width": 640,
|
||||
"text": "Description and properties appear here.",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Inventory Grid
|
||||
|
||||
Grid of cells for item management.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Inventory Grid",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 240, "top": 100, "width": 660, "height": 580,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||
},
|
||||
"title": {
|
||||
"type": "Text", "parent": "panel",
|
||||
"left": 260, "top": 120,
|
||||
"text": "INVENTORY",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"cell-1-1": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 260, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-2": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 332, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-3": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 404, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-4": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 476, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-1": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 260, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-2": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 332, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-3": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 404, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-4": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 476, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 580, "top": 160, "width": 300, "height": 400,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail-title": {
|
||||
"type": "Text", "parent": "detail",
|
||||
"left": 600, "top": 180,
|
||||
"text": "Selected Item Name",
|
||||
"fontColor": "#c8d0e0", "fontSize": 16
|
||||
},
|
||||
"detail-body": {
|
||||
"type": "Text", "parent": "detail",
|
||||
"left": 600, "top": 210, "width": 260,
|
||||
"text": "Item description and stats",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,53 +0,0 @@
|
||||
# Frame0 Setup Guide
|
||||
|
||||
## Installation (Fedora)
|
||||
|
||||
Download from https://frame0.app/download and install the RPM:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./frame0-*.x86_64.rpm
|
||||
```
|
||||
|
||||
Requires: Fedora 40 or later (x86_64).
|
||||
|
||||
## Starting Frame0
|
||||
|
||||
Launch the desktop application:
|
||||
|
||||
```bash
|
||||
frame0 &
|
||||
```
|
||||
|
||||
Frame0 exposes an HTTP API at `localhost:58320` when running.
|
||||
|
||||
## Verify API Access
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||
```
|
||||
|
||||
Expected output: `Frame0 is running on port 58320`
|
||||
|
||||
## Port Configuration
|
||||
|
||||
Default port: **58320**
|
||||
|
||||
To use a different port, set the environment variable:
|
||||
|
||||
```bash
|
||||
export FRAME0_PORT=58321
|
||||
```
|
||||
|
||||
Or pass `--port` to any script:
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| "Connection refused" | Frame0 not running | Start the desktop app |
|
||||
| "Port in use" | Another instance running | Close duplicate or use different port |
|
||||
| Script hangs | API unresponsive | Restart Frame0 |
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${FRAME0_PORT:-58320}"
|
||||
ENDPOINT="http://localhost:${PORT}/execute_command"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [args...] [--port N]
|
||||
|
||||
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
|
||||
curl calls. Requires Frame0 desktop app to be running.
|
||||
|
||||
Commands:
|
||||
health Check if Frame0 is running
|
||||
exec <namespace:action> <json> Execute a raw API command
|
||||
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
|
||||
get-shape <id> Get shape details
|
||||
update-shape <id> <json-props> Update shape properties
|
||||
delete <id> [id...] Delete shapes by ID
|
||||
move <id> <dx> <dy> Move a shape by pixel offset
|
||||
duplicate <id> Duplicate a shape
|
||||
group <id> [id...] Group shapes
|
||||
ungroup <group-id> Ungroup a group
|
||||
create-connector <tail-id> <head-id> [json-props] Connect two shapes
|
||||
create-icon <name> <json-props> Create an icon shape
|
||||
add-page <name> Add a new page (becomes current)
|
||||
get-page [page-id] Get current or specific page data
|
||||
list-pages [--shapes] List all pages (--shapes for shape data)
|
||||
current-page Get current page ID
|
||||
set-page <page-id> Set current page
|
||||
export [page-id] [--format mime] Export page as image (default: image/png)
|
||||
fit Fit view to screen
|
||||
|
||||
Options:
|
||||
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") health
|
||||
$(basename "$0") add-page "HUD Layout"
|
||||
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||
$(basename "$0") list-pages
|
||||
$(basename "$0") export --format image/png
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse --port from anywhere in args
|
||||
ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
# Execute a Frame0 API command, return data or error
|
||||
frame0_exec() {
|
||||
local command="$1"
|
||||
local args
|
||||
args="${2:-"{}"}"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
|
||||
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
|
||||
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != 2* ]]; then
|
||||
echo "ERROR: HTTP $http_code from Frame0" >&2
|
||||
echo "$body" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse success/error from response
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
r = json.loads(sys.stdin.read())
|
||||
if r.get('success'):
|
||||
d = r.get('data')
|
||||
if d is not None:
|
||||
print(json.dumps(d, indent=2))
|
||||
else:
|
||||
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" <<< "$body"
|
||||
}
|
||||
|
||||
# Build JSON array from remaining args
|
||||
ids_to_json_array() {
|
||||
local arr="["
|
||||
local first=true
|
||||
for id in "$@"; do
|
||||
[[ "$first" == true ]] && first=false || arr+=","
|
||||
arr+="\"$id\""
|
||||
done
|
||||
arr+="]"
|
||||
echo "$arr"
|
||||
}
|
||||
|
||||
CMD="${1:-}"
|
||||
shift || true
|
||||
|
||||
case "$CMD" in
|
||||
health)
|
||||
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
|
||||
echo "Frame0 is running on port $PORT"
|
||||
else
|
||||
echo "Frame0 is NOT running on port $PORT" >&2
|
||||
echo "Start Frame0 desktop app, then retry." >&2
|
||||
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
exec)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
|
||||
frame0_exec "$1" "$2"
|
||||
;;
|
||||
|
||||
create-shape)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
|
||||
local_type="$1"
|
||||
local_props="$2"
|
||||
local_parent="${3:-}"
|
||||
local_parent_arg=""
|
||||
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
|
||||
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
get-shape)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
|
||||
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
|
||||
;;
|
||||
|
||||
update-shape)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
|
||||
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
delete)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
|
||||
local_arr=$(ids_to_json_array "$@")
|
||||
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
|
||||
;;
|
||||
|
||||
move)
|
||||
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
|
||||
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
|
||||
;;
|
||||
|
||||
duplicate)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
|
||||
local_dx="${2:-0}"
|
||||
local_dy="${3:-0}"
|
||||
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
|
||||
;;
|
||||
|
||||
group)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
|
||||
local_arr=$(ids_to_json_array "$@")
|
||||
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
|
||||
;;
|
||||
|
||||
ungroup)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
|
||||
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
|
||||
;;
|
||||
|
||||
create-connector)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
|
||||
local_props="${3:-{}}"
|
||||
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
create-icon)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
|
||||
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
add-page)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
|
||||
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
|
||||
;;
|
||||
|
||||
get-page)
|
||||
if [[ $# -ge 1 ]]; then
|
||||
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
|
||||
else
|
||||
local_id
|
||||
local_id=$(frame0_exec "page:get-current-page")
|
||||
# Strip quotes from returned ID
|
||||
local_id=$(echo "$local_id" | tr -d '"')
|
||||
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
|
||||
fi
|
||||
;;
|
||||
|
||||
list-pages)
|
||||
local_shapes="false"
|
||||
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
|
||||
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
|
||||
;;
|
||||
|
||||
current-page)
|
||||
frame0_exec "page:get-current-page"
|
||||
;;
|
||||
|
||||
set-page)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
|
||||
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
|
||||
;;
|
||||
|
||||
export)
|
||||
local_page_id=""
|
||||
local_format="image/png"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--format) local_format="$2"; shift 2 ;;
|
||||
*) local_page_id="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
local_page_arg=""
|
||||
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
|
||||
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
|
||||
;;
|
||||
|
||||
fit)
|
||||
frame0_exec "view:fit-to-screen"
|
||||
;;
|
||||
|
||||
--help|-h|help)
|
||||
usage
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown command: $CMD" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch export wireframes from JSON to PNG via Frame0.
|
||||
#
|
||||
# Finds all .json wireframe files under docs/design/wireframes/ and exports
|
||||
# each to a matching .png. Skips files whose PNG is already newer than the
|
||||
# JSON, unless --force is passed.
|
||||
#
|
||||
# Usage:
|
||||
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Print manifest only, don't touch Frame0.
|
||||
# --force Re-export even if PNG already exists and is up to date.
|
||||
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
|
||||
# --root DIR Wireframes root dir (default: docs/design/wireframes)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 All exports succeeded (or nothing to do)
|
||||
# 1 One or more exports failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
SYNC="$SCRIPT_DIR/frame0-sync.py"
|
||||
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
|
||||
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
CATEGORY=""
|
||||
WF_ROOT="$DEFAULT_ROOT"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--category) CATEGORY="$2"; shift 2 ;;
|
||||
--root) WF_ROOT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '/^# /p' "$0" | sed 's/^# //'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$WF_ROOT" ]]; then
|
||||
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect JSON files, optionally filtered by category subdirectory
|
||||
mapfile -t JSON_FILES < <(
|
||||
if [[ -n "$CATEGORY" ]]; then
|
||||
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
|
||||
else
|
||||
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
|
||||
fi
|
||||
)
|
||||
|
||||
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
|
||||
echo "No wireframe JSON files found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Classify files into to-export and to-skip
|
||||
TO_EXPORT=()
|
||||
TO_SKIP=()
|
||||
|
||||
for json in "${JSON_FILES[@]}"; do
|
||||
png="${json%.json}.png"
|
||||
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
|
||||
TO_EXPORT+=("$json")
|
||||
else
|
||||
TO_SKIP+=("$json")
|
||||
fi
|
||||
done
|
||||
|
||||
# Print manifest
|
||||
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Will export (${#TO_EXPORT[@]} files):"
|
||||
for json in "${TO_EXPORT[@]}"; do
|
||||
png="${json%.json}.png"
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
if [[ ! -f "$png" ]]; then
|
||||
status="new"
|
||||
else
|
||||
status="updated"
|
||||
fi
|
||||
printf " [%-7s] %s\n" "$status" "$rel"
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "Nothing to export (all PNGs up to date)."
|
||||
fi
|
||||
|
||||
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
|
||||
for json in "${TO_SKIP[@]}"; do
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
printf " [skip ] %s\n" "$rel"
|
||||
done
|
||||
fi
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo ""
|
||||
echo "Dry run — no exports performed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
FAILED_FILES=()
|
||||
|
||||
TOTAL=${#TO_EXPORT[@]}
|
||||
IDX=0
|
||||
|
||||
for json in "${TO_EXPORT[@]}"; do
|
||||
IDX=$((IDX + 1))
|
||||
png="${json%.json}.png"
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
|
||||
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
|
||||
|
||||
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
|
||||
rc=$?
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
|
||||
echo "ok $size"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "FAILED"
|
||||
cat /tmp/frame0-batch-err.txt >&2
|
||||
FAILED=$((FAILED + 1))
|
||||
FAILED_FILES+=("$rel")
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "$PASSED exported, $FAILED failed."
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Failed:" >&2
|
||||
for f in "${FAILED_FILES[@]}"; do
|
||||
echo " $f" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,414 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
|
||||
|
||||
Local JSON is source of truth. Frame0 is a renderer.
|
||||
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
|
||||
|
||||
Usage:
|
||||
frame0-sync.py push <wireframe.json> [--port PORT]
|
||||
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
|
||||
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
DEFAULT_PORT = 58320
|
||||
|
||||
|
||||
def api(port, command, args=None):
|
||||
"""Execute a Frame0 API command. Returns the data field on success."""
|
||||
url = f"http://localhost:{port}/execute_command"
|
||||
payload = json.dumps({"command": command, "args": args or {}}).encode()
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not result.get("success"):
|
||||
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return result.get("data")
|
||||
|
||||
|
||||
# -- Mapping file management --------------------------------------------------
|
||||
|
||||
def mapping_path(wireframe_path):
|
||||
"""Derive the idmap path from the wireframe JSON path."""
|
||||
d = os.path.dirname(wireframe_path)
|
||||
base = os.path.splitext(os.path.basename(wireframe_path))[0]
|
||||
return os.path.join(d, f".{base}.idmap.json")
|
||||
|
||||
|
||||
def load_mapping(wireframe_path):
|
||||
p = mapping_path(wireframe_path)
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
return json.load(f)
|
||||
return {"page_id": None, "shapes": {}, "connectors": {}}
|
||||
|
||||
|
||||
def save_mapping(wireframe_path, mapping):
|
||||
p = mapping_path(wireframe_path)
|
||||
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||
with open(p, "w") as f:
|
||||
json.dump(mapping, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
# -- Push: local JSON -> Frame0 -----------------------------------------------
|
||||
|
||||
# Frame0 returns different type names from get vs what create accepts.
|
||||
# Map internal types back to create-API types.
|
||||
TYPE_TO_CREATE = {
|
||||
"Box": "Rectangle",
|
||||
"box": "Rectangle",
|
||||
}
|
||||
|
||||
|
||||
def topo_sort_shapes(shapes):
|
||||
"""Sort shape IDs so parents come before children."""
|
||||
order = []
|
||||
visited = set()
|
||||
|
||||
def visit(sid):
|
||||
if sid in visited:
|
||||
return
|
||||
visited.add(sid)
|
||||
shape = shapes[sid]
|
||||
parent = shape.get("parent")
|
||||
if parent and parent in shapes:
|
||||
visit(parent)
|
||||
order.append(sid)
|
||||
|
||||
for sid in shapes:
|
||||
visit(sid)
|
||||
return order
|
||||
|
||||
|
||||
def find_or_create_page(port, name, mapping):
|
||||
"""Find existing page by mapping or name, or create a new one."""
|
||||
# Try mapped page_id first
|
||||
if mapping.get("page_id"):
|
||||
try:
|
||||
page = api(port, "page:get", {"pageId": mapping["page_id"]})
|
||||
if page:
|
||||
return mapping["page_id"]
|
||||
except SystemExit:
|
||||
pass # Page no longer exists, fall through
|
||||
|
||||
# Search by name in existing pages
|
||||
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||
if doc and "children" in doc:
|
||||
for page in doc["children"]:
|
||||
if page.get("name") == name:
|
||||
return page["id"]
|
||||
|
||||
# Create new page
|
||||
page = api(port, "page:add", {"pageProps": {"name": name}})
|
||||
return page["id"]
|
||||
|
||||
|
||||
def clear_page(port, page_id):
|
||||
"""Delete all shapes on a page."""
|
||||
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||
if not page or "children" not in page:
|
||||
return
|
||||
shape_ids = [s["id"] for s in page["children"]]
|
||||
if shape_ids:
|
||||
api(port, "edit:delete", {"shapeIdArray": shape_ids})
|
||||
|
||||
|
||||
def push(wireframe_path, port):
|
||||
"""Push local wireframe JSON to Frame0."""
|
||||
with open(wireframe_path) as f:
|
||||
wireframe = json.load(f)
|
||||
|
||||
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
|
||||
shapes = wireframe.get("shapes", {})
|
||||
connectors = wireframe.get("connectors", {})
|
||||
|
||||
mapping = load_mapping(wireframe_path)
|
||||
|
||||
# Find or create page
|
||||
page_id = find_or_create_page(port, name, mapping)
|
||||
mapping["page_id"] = page_id
|
||||
|
||||
# Switch to page and clear it
|
||||
api(port, "page:set-current-page", {"pageId": page_id})
|
||||
clear_page(port, page_id)
|
||||
|
||||
# Reset ID mappings (shapes are recreated)
|
||||
mapping["shapes"] = {}
|
||||
mapping["connectors"] = {}
|
||||
|
||||
# Create shapes in dependency order
|
||||
order = topo_sort_shapes(shapes)
|
||||
for local_id in order:
|
||||
shape = shapes[local_id]
|
||||
raw_type = shape.get("type", "Rectangle")
|
||||
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
|
||||
parent_local = shape.get("parent")
|
||||
|
||||
# Build shapeProps from everything except meta fields
|
||||
meta_keys = {"type", "parent"}
|
||||
props = {k: v for k, v in shape.items() if k not in meta_keys}
|
||||
|
||||
# Set name to local_id if not explicitly set
|
||||
if "name" not in props:
|
||||
props["name"] = local_id
|
||||
|
||||
create_args = {
|
||||
"type": shape_type,
|
||||
"shapeProps": props,
|
||||
"convertColors": True,
|
||||
}
|
||||
|
||||
# Resolve parent ID
|
||||
if parent_local and parent_local in mapping["shapes"]:
|
||||
create_args["parentId"] = mapping["shapes"][parent_local]
|
||||
|
||||
f0_id = api(port, "shape:create-shape", create_args)
|
||||
mapping["shapes"][local_id] = f0_id
|
||||
|
||||
# Create connectors
|
||||
for local_id, conn in connectors.items():
|
||||
tail_local = conn.get("tailId")
|
||||
head_local = conn.get("headId")
|
||||
|
||||
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
|
||||
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
meta_keys = {"tailId", "headId"}
|
||||
props = {k: v for k, v in conn.items() if k not in meta_keys}
|
||||
if "name" not in props:
|
||||
props["name"] = local_id
|
||||
|
||||
f0_id = api(port, "shape:create-connector", {
|
||||
"tailId": mapping["shapes"][tail_local],
|
||||
"headId": mapping["shapes"][head_local],
|
||||
"shapeProps": props,
|
||||
"convertColors": True,
|
||||
})
|
||||
mapping["connectors"][local_id] = f0_id
|
||||
|
||||
# Fit to screen
|
||||
api(port, "view:fit-to-screen")
|
||||
|
||||
save_mapping(wireframe_path, mapping)
|
||||
total = len(mapping["shapes"]) + len(mapping["connectors"])
|
||||
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
|
||||
|
||||
|
||||
# -- Pull: Frame0 -> local JSON -----------------------------------------------
|
||||
|
||||
def pull(page_ref, output_path, port):
|
||||
"""Pull a Frame0 page into local wireframe JSON."""
|
||||
# Resolve page_ref: could be an ID or a name
|
||||
page_id = None
|
||||
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||
if doc and "children" in doc:
|
||||
for page in doc["children"]:
|
||||
if page["id"] == page_ref or page.get("name") == page_ref:
|
||||
page_id = page["id"]
|
||||
page_name = page.get("name", page_ref)
|
||||
break
|
||||
|
||||
if not page_id:
|
||||
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load existing mapping for reverse lookup
|
||||
mapping = load_mapping(output_path)
|
||||
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
|
||||
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
|
||||
|
||||
# Get full page with shapes
|
||||
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||
|
||||
shapes = {}
|
||||
connectors = {}
|
||||
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
|
||||
auto_id_counter = [0]
|
||||
|
||||
def auto_id(f0_shape):
|
||||
"""Generate a stable local ID from shape name or auto-number."""
|
||||
# Prefer existing mapping
|
||||
f0_id = f0_shape["id"]
|
||||
if f0_id in reverse_map:
|
||||
return reverse_map[f0_id]
|
||||
# Use sanitized name
|
||||
name = f0_shape.get("name", "")
|
||||
if name:
|
||||
sanitized = name.lower().replace(" ", "-").replace("_", "-")
|
||||
if sanitized not in shapes:
|
||||
return sanitized
|
||||
# Fallback: auto-number
|
||||
auto_id_counter[0] += 1
|
||||
return f"s{auto_id_counter[0]:03d}"
|
||||
|
||||
def process_shape(f0_shape, parent_local_id=None):
|
||||
f0_id = f0_shape["id"]
|
||||
local_id = auto_id(f0_shape)
|
||||
new_mapping["shapes"][local_id] = f0_id
|
||||
|
||||
# Extract shape properties — only strip structural keys that our
|
||||
# ID mapping replaces. Everything else passes through as-is so the
|
||||
# local JSON speaks Frame0's native vocabulary.
|
||||
shape_type = f0_shape.get("type", "Box")
|
||||
skip_keys = {"id", "type", "children", "pageId", "parentId"}
|
||||
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
|
||||
|
||||
entry = {"type": shape_type}
|
||||
if parent_local_id:
|
||||
entry["parent"] = parent_local_id
|
||||
entry.update(props)
|
||||
|
||||
# Remove name if it matches local_id (redundant)
|
||||
if entry.get("name") == local_id:
|
||||
del entry["name"]
|
||||
|
||||
shapes[local_id] = entry
|
||||
|
||||
# Process children recursively
|
||||
for child in f0_shape.get("children", []):
|
||||
child_type = child.get("type", "")
|
||||
if child_type == "Connector":
|
||||
process_connector(child)
|
||||
else:
|
||||
process_shape(child, local_id)
|
||||
|
||||
def process_connector(f0_conn):
|
||||
f0_id = f0_conn["id"]
|
||||
local_id = reverse_conn.get(f0_id)
|
||||
if not local_id:
|
||||
auto_id_counter[0] += 1
|
||||
local_id = f"c{auto_id_counter[0]:03d}"
|
||||
|
||||
new_mapping["connectors"][local_id] = f0_id
|
||||
|
||||
tail_f0 = f0_conn.get("tail", {}).get("id")
|
||||
head_f0 = f0_conn.get("head", {}).get("id")
|
||||
|
||||
entry = {}
|
||||
if tail_f0:
|
||||
# Will be resolved after all shapes are processed
|
||||
entry["_tailF0"] = tail_f0
|
||||
if head_f0:
|
||||
entry["_headF0"] = head_f0
|
||||
|
||||
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
|
||||
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
|
||||
entry.update(props)
|
||||
|
||||
connectors[local_id] = entry
|
||||
|
||||
# Process all top-level shapes
|
||||
for child in page.get("children", []):
|
||||
child_type = child.get("type", "")
|
||||
if child_type == "Connector":
|
||||
process_connector(child)
|
||||
else:
|
||||
process_shape(child)
|
||||
|
||||
# Resolve connector references to local IDs
|
||||
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
|
||||
for conn in connectors.values():
|
||||
tail_f0 = conn.pop("_tailF0", None)
|
||||
head_f0 = conn.pop("_headF0", None)
|
||||
if tail_f0 and tail_f0 in f0_to_local:
|
||||
conn["tailId"] = f0_to_local[tail_f0]
|
||||
if head_f0 and head_f0 in f0_to_local:
|
||||
conn["headId"] = f0_to_local[head_f0]
|
||||
|
||||
wireframe = {"name": page_name}
|
||||
if shapes:
|
||||
wireframe["shapes"] = shapes
|
||||
if connectors:
|
||||
wireframe["connectors"] = connectors
|
||||
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(wireframe, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
save_mapping(output_path, new_mapping)
|
||||
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
|
||||
|
||||
|
||||
# -- Export: push then export as image -----------------------------------------
|
||||
|
||||
def export_image(wireframe_path, output_path, port, fmt="image/png"):
|
||||
"""Push wireframe to Frame0 and export the page as an image."""
|
||||
import base64
|
||||
|
||||
# Push first to ensure Frame0 is up to date
|
||||
push(wireframe_path, port)
|
||||
|
||||
mapping = load_mapping(wireframe_path)
|
||||
page_id = mapping.get("page_id")
|
||||
if not page_id:
|
||||
print("ERROR: No page_id in mapping after push", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
image_b64 = api(port, "file:export-image", {
|
||||
"pageId": page_id,
|
||||
"format": fmt,
|
||||
"fillBackground": True,
|
||||
})
|
||||
|
||||
image_bytes = base64.b64decode(image_b64)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
|
||||
|
||||
|
||||
# -- CLI -----------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
|
||||
p_push.add_argument("wireframe", help="Path to wireframe .json file")
|
||||
|
||||
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
|
||||
p_pull.add_argument("page", help="Page ID or page name")
|
||||
p_pull.add_argument("output", help="Output .json path")
|
||||
|
||||
p_export = sub.add_parser("export", help="Push and export as image")
|
||||
p_export.add_argument("wireframe", help="Path to wireframe .json file")
|
||||
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
|
||||
p_export.add_argument("--format", default="image/png",
|
||||
help="Export MIME type (default: image/png)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "push":
|
||||
push(args.wireframe, args.port)
|
||||
elif args.command == "pull":
|
||||
pull(args.page, args.output, args.port)
|
||||
elif args.command == "export":
|
||||
export_image(args.wireframe, args.output, args.port, args.format)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,17 +30,24 @@ This repo uses [Conventional Commits 1.0](https://www.conventionalcommits.org/en
|
||||
Default to **one logical change per commit**, even when a lot of work lands in the tree at once. When there's a pile of uncommitted or untracked files:
|
||||
|
||||
1. **Read `git status` + `git diff` first** — never stage the whole tree blind.
|
||||
2. **Group by concern**, not by file location. Typical concerns to separate:
|
||||
2. **Triage: session vs pre-existing.** Separate changes you made in this
|
||||
session from changes that were already dirty (or that a concurrent session
|
||||
is producing — this worktree can host several Claude sessions at once).
|
||||
Check the conversation history — if you didn't touch a file, it's not yours
|
||||
to stage. After grouping your own commits, surface any remaining modified
|
||||
or untracked files to the user and ask whether they belong in a commit.
|
||||
Never silently skip or silently include changes that aren't yours.
|
||||
3. **Group by concern**, not by file location. Typical concerns to separate:
|
||||
- **Bookkeeping** — `.gitignore`, editor/IDE config, lockfiles, `go.sum` / `pubspec.lock` updates.
|
||||
- **Documentation** — `README.md`, `CLAUDE.md`, ADRs under `docs/ADRs/`, design notes under `docs/`.
|
||||
- **Documentation** — `README.md`, `CLAUDE.md`, D-records under `governance/`, design notes under `docs/`.
|
||||
- **General-purpose tooling / skills** — things that aren't project-specific (reusable skills, shared scripts).
|
||||
- **Project-specific conventions** — this repo's own rules.
|
||||
- **Feature or subsystem** — one cohesive change per commit; a sidecar change and an app change for the same feature can land together, but two unrelated features should split.
|
||||
- **Layer changes** — Flutter app, sidecar CLI, sidecar daemon, IPC server, pql wrapper, canvas driver, git panel — separate concerns; prefer separate commits when the changes are independent.
|
||||
3. **Sequence the commits** so each one is cleanly scoped, but don't obsess about whether each intermediate commit "works" — for scaffolding PRs it's fine if the full picture only snaps together at the end.
|
||||
4. **Prefer many small focused commits over one large mixed one** — a reviewer can read, revert, or cherry-pick a focused commit; they can't do any of those to a blob.
|
||||
5. **Use `git add <specific paths>`** — never `git add -A` or `git add .` when splitting, or you'll sweep in the next commit's work by accident.
|
||||
6. **Verify between commits** with `git status` and `git log -1` to confirm the split landed as intended.
|
||||
- **Feature or subsystem** — one cohesive change per commit; a CLI change and an app change for the same feature can land together, but two unrelated features should split.
|
||||
- **Layer changes** — Flutter app, `clide` CLI (`native/clide-cli/`), IPC server, pql wrapper, canvas driver, git panel — separate concerns; prefer separate commits when the changes are independent.
|
||||
4. **Sequence the commits** so each one is cleanly scoped, but don't obsess about whether each intermediate commit "works" — for scaffolding PRs it's fine if the full picture only snaps together at the end.
|
||||
5. **Prefer many small focused commits over one large mixed one** — a reviewer can read, revert, or cherry-pick a focused commit; they can't do any of those to a blob.
|
||||
6. **Use `git add <specific paths>`** — never `git add -A` or `git add .` when splitting, or you'll sweep in the next commit's work by accident.
|
||||
7. **Verify between commits** with `git status` and `git log -1` to confirm the split landed as intended.
|
||||
|
||||
Corollary: if a commit's subject line needs the word "and" to be accurate, it probably should have been two commits.
|
||||
|
||||
@@ -99,6 +106,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.
|
||||
|
||||
@@ -112,29 +120,32 @@ Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
|
||||
The model-identifier variant produced by the Claude Code harness (e.g. `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`) is also accepted — don't rewrite it if the harness emits that form.
|
||||
|
||||
## HEREDOC discipline
|
||||
## Message-file discipline
|
||||
|
||||
Pass commit messages via HEREDOC so multi-line formatting survives:
|
||||
Pass commit messages via a message file so multi-line formatting survives
|
||||
and the command stays inside the permission allowlist (command substitution
|
||||
defeats the `git commit` prefix match and triggers permission prompts):
|
||||
|
||||
1. Write the full message to a file in `/tmp` (never inside `.git/` — that
|
||||
directory is git's own state) using the Write tool.
|
||||
2. Commit with it:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
short imperative summary
|
||||
|
||||
Optional longer body explaining why this change was needed,
|
||||
wrapped at about 72 characters.
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git commit -F /tmp/commit-msg.txt
|
||||
```
|
||||
|
||||
Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs between shells and quoting regimes and the trailer can end up in the wrong place.
|
||||
Fallback only: the HEREDOC-in-substitution form
|
||||
(`git commit -m "$(cat <<'EOF' … EOF)"`) works but prompts for permission —
|
||||
use it only when writing a file is impossible. Never pass multi-line messages
|
||||
via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs
|
||||
between shells and quoting regimes and the trailer can end up in the wrong
|
||||
place.
|
||||
|
||||
## What not to commit
|
||||
|
||||
- `.env` and any `*.env.local` — see `.gitignore`.
|
||||
- `.claude/settings.local.json` — user-specific Claude Code settings, ignored.
|
||||
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/` (Flutter output), `app/.dart_tool/` — gitignored.
|
||||
- Build artefacts: `/build/` (Flutter output), `/.dart_tool/` — gitignored.
|
||||
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
|
||||
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: penpot-login
|
||||
description: Connect to Penpot MCP - starts the MCP server, logs into penpot.schweitz.net, and installs/connects the plugin
|
||||
allowed-tools: Read, Bash, mcp__chrome-devtools__new_page, mcp__chrome-devtools__navigate_page, mcp__chrome-devtools__take_snapshot, mcp__chrome-devtools__take_screenshot, mcp__chrome-devtools__click, mcp__chrome-devtools__fill, mcp__chrome-devtools__press_key, mcp__chrome-devtools__list_pages
|
||||
---
|
||||
|
||||
# Penpot MCP Connection
|
||||
|
||||
This skill connects Claude to Penpot by:
|
||||
1. Starting the Penpot MCP server (if not running)
|
||||
2. Logging into penpot.schweitz.net via Authentik
|
||||
3. Installing and connecting the Penpot MCP plugin
|
||||
|
||||
## Step 1: Start MCP Server
|
||||
|
||||
Check if the MCP server is running, start it if not:
|
||||
|
||||
```bash
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:9880/mcp > /dev/null 2>&1; then
|
||||
# Start the server in background
|
||||
cd ~/Projects/penpot-mcp && ./start-server.sh &
|
||||
sleep 3 # Wait for server to start
|
||||
fi
|
||||
```
|
||||
|
||||
Server endpoints when running:
|
||||
- MCP Server: http://localhost:9880/mcp
|
||||
- Plugin Server: http://localhost:9879/
|
||||
- WebSocket: ws://localhost:4402
|
||||
|
||||
## Step 2: Login Credentials
|
||||
|
||||
Read credentials from the project root file `claude-authentik-credentials.md`:
|
||||
!`cat claude-authentik-credentials.md`
|
||||
|
||||
## Step 3: Login Flow
|
||||
|
||||
1. **Navigate to Penpot**
|
||||
- Use `mcp__chrome-devtools__new_page` to go to `https://penpot.schweitz.net`
|
||||
|
||||
2. **Check if already logged in**
|
||||
- Take a snapshot
|
||||
- If you see "Projects" heading, you're logged in - skip to Step 4
|
||||
- If you see login page, continue with authentication
|
||||
|
||||
3. **Authenticate via Authentik** (if not logged in)
|
||||
- Click the OpenID button to redirect to Authentik
|
||||
- On auth.schweitz.net, fill username textbox
|
||||
- Click "Log in" button
|
||||
- Fill password textbox
|
||||
- Click "Continue" button
|
||||
- If redirected to Authentik user page instead of Penpot, navigate back to `https://penpot.schweitz.net`
|
||||
|
||||
## Step 4: Open Design File
|
||||
|
||||
1. **Navigate to the Clide project**
|
||||
- Double-click on the TUI file to open the workspace
|
||||
|
||||
## Step 5: Install/Connect Plugin
|
||||
|
||||
1. **Open Plugins menu**
|
||||
- Click the Plugins button (puzzle icon, keyboard shortcut: Cmd+Alt+P)
|
||||
|
||||
2. **Check if plugin is installed**
|
||||
- If "Penpot MCP Plugin" appears under "INSTALLED PLUGINS", click OPEN
|
||||
- Otherwise, install it first:
|
||||
|
||||
3. **Install plugin** (if not installed)
|
||||
- Fill the plugin URL textbox with: `http://localhost:9879/manifest.json`
|
||||
- Click INSTALL
|
||||
- Click ALLOW on the permissions dialog
|
||||
|
||||
4. **Connect to MCP server**
|
||||
- In the plugin UI, click "CONNECT TO MCP SERVER"
|
||||
- Verify it shows "Connected to MCP server"
|
||||
|
||||
## Step 6: Configure Claude Code MCP
|
||||
|
||||
Add the Penpot MCP server to Claude Code (if not already configured):
|
||||
|
||||
```bash
|
||||
claude mcp add penpot -t http http://localhost:9880/mcp
|
||||
```
|
||||
|
||||
**Important**: Use HTTP transport (`-t http`) with the `/mcp` endpoint. Do NOT use SSE transport - it causes "Server not initialized" errors.
|
||||
|
||||
## Step 7: Verify Connection
|
||||
|
||||
After connecting, restart the MCP connection in Claude Code:
|
||||
- Use `/mcp` command to reconnect the penpot server
|
||||
- The Penpot MCP tools (execute_code, export_shape, etc.) will then be available
|
||||
|
||||
Test with:
|
||||
```javascript
|
||||
mcp__penpot__execute_code(code="return penpot.currentPage.name;")
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Keep the Penpot plugin UI window open while using MCP tools
|
||||
- The MCP server must be running for the plugin to connect
|
||||
- If connection fails, check that the server is running on port 9880
|
||||
- Use HTTP transport (`-t http`), NOT SSE transport
|
||||
@@ -1,203 +0,0 @@
|
||||
---
|
||||
name: skill-create
|
||||
description: >
|
||||
Guidance for creating effective Claude Code skills (.skill packages).
|
||||
Use when the user wants to create, build, design, or iterate on a skill —
|
||||
including writing SKILL.md files, bundling scripts/references/assets,
|
||||
initializing new skills, packaging skills, or improving existing ones.
|
||||
Triggers on requests like "create a skill", "make a new skill",
|
||||
"build a skill for X", "package this skill", or "improve my skill".
|
||||
---
|
||||
|
||||
|
||||
# Skill Creator
|
||||
|
||||
## About Skills
|
||||
|
||||
Skills are modular, self-contained packages that extend Claude's capabilities
|
||||
by providing specialized knowledge, workflows, and tools. They transform Claude
|
||||
from a general-purpose agent into a specialized agent equipped with procedural
|
||||
knowledge that no model can fully possess.
|
||||
|
||||
### What Skills Provide
|
||||
|
||||
- **Specialized workflows** — Multi-step procedures for specific domains
|
||||
- **Tool integrations** — Instructions for working with specific file formats or APIs
|
||||
- **Domain expertise** — Company-specific knowledge, schemas, business logic
|
||||
- **Bundled resources** — Scripts, references, and assets for complex and repetitive tasks
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Concise is Key
|
||||
|
||||
The context window is a public good. Skills share it with everything else Claude
|
||||
needs: system prompt, conversation history, other skills' metadata, and the
|
||||
actual user request.
|
||||
|
||||
Default assumption: Claude is already very smart. Only add context Claude doesn't
|
||||
already have. Challenge each piece of information: "Does Claude really need this
|
||||
explanation?" and "Does this paragraph justify its token cost?"
|
||||
|
||||
Prefer concise examples over verbose explanations.
|
||||
|
||||
### Set Appropriate Degrees of Freedom
|
||||
|
||||
Match specificity to the task's fragility and variability:
|
||||
|
||||
- **High freedom** (text-based instructions): Multiple approaches valid, decisions
|
||||
depend on context, heuristics guide the approach.
|
||||
- **Medium freedom** (pseudocode or scripts with parameters): Preferred pattern
|
||||
exists, some variation acceptable, configuration affects behavior.
|
||||
- **Low freedom** (specific scripts, few parameters): Operations are fragile and
|
||||
error-prone, consistency is critical, specific sequence must be followed.
|
||||
|
||||
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific
|
||||
guardrails (low freedom), while an open field allows many routes (high freedom).
|
||||
|
||||
## Anatomy of a Skill
|
||||
|
||||
```
|
||||
skill-name/
|
||||
├── SKILL.md (required)
|
||||
│ ├── YAML frontmatter metadata (required)
|
||||
│ │ ├── name: (required)
|
||||
│ │ ├── description: (required)
|
||||
│ │ └── compatibility: (optional, rarely needed)
|
||||
│ └── Markdown instructions (required)
|
||||
└── Bundled Resources (optional)
|
||||
├── scripts/ - Executable code (Python/Bash/etc.)
|
||||
├── references/ - Documentation loaded into context as needed
|
||||
└── assets/ - Files used in output (templates, icons, fonts, etc.)
|
||||
```
|
||||
|
||||
### SKILL.md (required)
|
||||
|
||||
- **Frontmatter (YAML)**: `name` and `description` fields (required). Only these
|
||||
are read by Claude to determine when the skill triggers — be clear and
|
||||
comprehensive. The `compatibility` field is for environment requirements but
|
||||
most skills don't need it.
|
||||
- **Body (Markdown)**: Instructions and guidance. Only loaded AFTER the skill
|
||||
triggers.
|
||||
|
||||
### Bundled Resources (optional)
|
||||
|
||||
**Scripts (`scripts/`)** — Executable code for tasks requiring deterministic
|
||||
reliability or that are repeatedly rewritten.
|
||||
|
||||
**References (`references/`)** — Documentation loaded as needed into context.
|
||||
Keep SKILL.md lean; move detailed reference material, schemas, and examples here.
|
||||
If files are large (>10k words), include grep search patterns in SKILL.md.
|
||||
|
||||
**Assets (`assets/`)** — Files used in output, not loaded into context (templates,
|
||||
images, icons, boilerplate code, fonts).
|
||||
|
||||
### What to NOT Include
|
||||
|
||||
Do NOT create extraneous files like README.md, INSTALLATION_GUIDE.md,
|
||||
QUICK_REFERENCE.md, CHANGELOG.md, etc. The skill should only contain information
|
||||
needed for an AI agent to do the job.
|
||||
|
||||
## Progressive Disclosure
|
||||
|
||||
Skills use a three-level loading system:
|
||||
|
||||
1. **Metadata** (name + description) — Always in context (~100 words)
|
||||
2. **SKILL.md body** — When skill triggers (<5k words)
|
||||
3. **Bundled resources** — As needed (unlimited; scripts can run without reading)
|
||||
|
||||
Keep SKILL.md body under 500 lines. Split content into separate files when
|
||||
approaching this limit. Reference split files from SKILL.md with clear
|
||||
descriptions of when to read them.
|
||||
|
||||
### Disclosure Patterns
|
||||
|
||||
**Pattern 1: High-level guide with references** — Keep overview in SKILL.md,
|
||||
link to detail files loaded only when needed.
|
||||
|
||||
**Pattern 2: Domain-specific organization** — Organize content by domain
|
||||
(e.g., `references/finance.md`, `references/sales.md`) so only relevant content
|
||||
is loaded.
|
||||
|
||||
**Pattern 3: Conditional details** — Show basic content, link to advanced
|
||||
content loaded only when the user needs those features.
|
||||
|
||||
Guidelines:
|
||||
- Avoid deeply nested references — keep one level deep from SKILL.md
|
||||
- Structure longer reference files with a table of contents at the top
|
||||
|
||||
## Skill Creation Process
|
||||
|
||||
Follow these steps in order, skipping only with clear reason:
|
||||
|
||||
### Step 1: Understand the Skill with Concrete Examples
|
||||
|
||||
Skip only when usage patterns are already clearly understood.
|
||||
|
||||
Ask the user for concrete examples of how the skill will be used:
|
||||
- "What functionality should the skill support?"
|
||||
- "Can you give some examples of how this skill would be used?"
|
||||
- "What would a user say that should trigger this skill?"
|
||||
|
||||
Avoid overwhelming users — start with the most important questions.
|
||||
|
||||
### Step 2: Plan the Reusable Skill Contents
|
||||
|
||||
Analyze each example by considering how to execute from scratch and identifying
|
||||
what scripts, references, and assets would help with repeated execution.
|
||||
|
||||
Establish a list of reusable resources: scripts, references, and assets.
|
||||
|
||||
### Step 3: Initialize the Skill
|
||||
|
||||
Create the skill directory manually:
|
||||
|
||||
```
|
||||
mkdir -p <output-directory>/<skill-name>
|
||||
```
|
||||
|
||||
Then create `SKILL.md` with frontmatter and body. Add `scripts/`, `references/`,
|
||||
and `assets/` subdirectories only as needed.
|
||||
|
||||
Skip if iterating on an existing skill.
|
||||
|
||||
### Step 4: Edit the Skill
|
||||
|
||||
Remember the skill is for another Claude instance to use. Include beneficial,
|
||||
non-obvious information.
|
||||
|
||||
For design patterns, consult:
|
||||
- `references/workflows.md` — Sequential workflows and conditional logic
|
||||
- `references/output-patterns.md` — Template and example patterns
|
||||
|
||||
**Implementation order:**
|
||||
1. Start with reusable resources (`scripts/`, `references/`, `assets/`)
|
||||
2. Test added scripts by running them
|
||||
3. Delete unused example files from initialization
|
||||
4. Update SKILL.md
|
||||
|
||||
**Writing guidelines:** Always use imperative/infinitive form.
|
||||
|
||||
**Frontmatter:**
|
||||
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
|
||||
The domain is the system/area the skill operates on, the action is what it does.
|
||||
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
|
||||
Multi-action wrappers (like `ticket`) can use the domain name alone.
|
||||
The directory name must match the `name` field.
|
||||
- `description`: Primary triggering mechanism. Include what the skill does AND
|
||||
specific triggers/contexts. All "when to use" info goes here (not in body).
|
||||
|
||||
**Body:** Instructions for using the skill and its bundled resources.
|
||||
|
||||
### Step 5: Validate the Skill
|
||||
|
||||
Check the skill manually:
|
||||
- Frontmatter has `name` and `description`
|
||||
- SKILL.md body is under 500 lines
|
||||
- No extraneous files (README.md, CHANGELOG.md, etc.)
|
||||
- Scripts are executable and tested
|
||||
- References are referenced from SKILL.md
|
||||
|
||||
### Step 6: Iterate
|
||||
|
||||
After real usage, notice struggles or inefficiencies, identify needed updates,
|
||||
implement changes, and test again.
|
||||
@@ -6,9 +6,10 @@ description: >
|
||||
lifecycle, theme loading, or native platform code. Also use when
|
||||
the user says "run testmode", "test the app", "smoke test", or
|
||||
"verify the build". Triggers on changes to: lib/kernel/src/toolchain.dart,
|
||||
lib/src/daemon/dispatcher.dart, lib/src/ipc/, lib/extension/,
|
||||
lib/kernel/src/theme/, lib/builtin/*/src/extension.dart,
|
||||
linux/CMakeLists.txt, macos/, Makefile (run targets), lib/main.dart.
|
||||
lib/src/daemon/dispatcher.dart, lib/src/ipc/, lib/src/pty/,
|
||||
lib/builtin/terminal/, lib/extension/, lib/kernel/src/theme/,
|
||||
lib/builtin/*/src/extension.dart, linux/CMakeLists.txt, macos/,
|
||||
Makefile (run targets), lib/main.dart.
|
||||
---
|
||||
|
||||
# Testmode harness
|
||||
@@ -26,6 +27,7 @@ make run-testmode # all categories, 60s timeout
|
||||
make run-testmode TESTMODE_CATEGORY=toolchain
|
||||
make run-testmode TESTMODE_CATEGORY=ipc
|
||||
make run-testmode TESTMODE_CATEGORY=extensions
|
||||
make run-testmode TESTMODE_CATEGORY=terminal
|
||||
make run-testmode TESTMODE_TIMEOUT=120 # longer timeout for slow builds
|
||||
```
|
||||
|
||||
@@ -47,6 +49,7 @@ Exit code is non-zero when any test fails. The Makefile verifies
|
||||
| IPC envelope, dispatcher, schema | `ipc` | Round-trip + error contract |
|
||||
| Extension manifest, activate, contributions | `extensions` | Register + activate lifecycle |
|
||||
| Theme YAML, loader, palette | `extensions` | Theme parse is in this category |
|
||||
| PTY spawning, terminal pane, xterm wiring | `terminal` | Real PTY spawn + I/O round-trip |
|
||||
| Platform config (CMakeLists, pbxproj, Makefile) | `all` | Full rebuild validates everything |
|
||||
| Any doubt | `all` | ~30s, cheap insurance |
|
||||
|
||||
|
||||
@@ -3,27 +3,22 @@
|
||||
|
||||
Mirrors `assets/fonts/phosphor/codepoints.csv` (the full bundled glyph set)
|
||||
into a readable, greppable markdown table at
|
||||
`.claude/skills/ui-design/references/phosphor-glyphs.md`, flagging which
|
||||
glyphs clide already defines in `lib/widgets/src/icons/phosphor.dart`.
|
||||
`.claude/skills/ui-design/references/phosphor-glyphs.md`.
|
||||
|
||||
That table is also the source of truth for the `kPhosphorGlyphs` map — after
|
||||
regenerating it, re-run `dart run tool/gen_phosphor_glyphs.dart` so
|
||||
`lib/widgets/src/icons/phosphor_glyphs.g.dart` stays in sync.
|
||||
|
||||
Run from the repo root: python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py
|
||||
"""
|
||||
import csv
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||
CSV = ROOT / "assets/fonts/phosphor/codepoints.csv"
|
||||
DART = ROOT / "lib/widgets/src/icons/phosphor.dart"
|
||||
OUT = ROOT / ".claude/skills/ui-design/references/phosphor-glyphs.md"
|
||||
|
||||
# Codepoint -> camelCase accessor already defined in PhosphorIcons.
|
||||
defined = {}
|
||||
for m in re.finditer(r"static const (\w+) = PhosphorIconPainter\((0x[0-9a-fA-F]+)\)", DART.read_text()):
|
||||
defined[int(m.group(2), 16)] = m.group(1)
|
||||
|
||||
rows = list(csv.DictReader(CSV.open()))
|
||||
n_def = sum(1 for r in rows if int(r["codepoint"], 16) in defined)
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
@@ -37,22 +32,21 @@ lines = [
|
||||
f"All **{len(rows)}** glyphs bundled in clide's Phosphor font "
|
||||
"(`assets/fonts/phosphor/`, MIT). Generated from "
|
||||
"`assets/fonts/phosphor/codepoints.csv` — **do not hand-edit**; regenerate with "
|
||||
"`python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py`.",
|
||||
"`python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py`, then re-run "
|
||||
"`dart run tool/gen_phosphor_glyphs.dart` (this table is the source for the "
|
||||
"generated `kPhosphorGlyphs` map).",
|
||||
"",
|
||||
f"The **In clide** column flags the **{n_def}** glyphs already wired into "
|
||||
"`PhosphorIcons` (`lib/widgets/src/icons/phosphor.dart`) — reach for those first. "
|
||||
"To use any other glyph, add a one-line `static const` to that class with the "
|
||||
"codepoint below, then `ClideIcon(PhosphorIcons.<name>, size: 13)`. Keep additions "
|
||||
"to icons we actually use — don't bulk-import.",
|
||||
"Every glyph is available in code via `PhosphorIcons.byName('<kebab-name>')` "
|
||||
"(T-314) — e.g. `ClideIcon(PhosphorIcons.byName('folder'), size: 13)`. Raw "
|
||||
"codepoints never appear in feature code; they live only in the generated map. "
|
||||
"An unknown name renders the `placeholder` glyph, and `phosphor_glyphs_test` "
|
||||
"asserts every `byName('…')` literal in `lib/` resolves.",
|
||||
"",
|
||||
"| Codepoint | Name (kebab) | Pascal | In clide |",
|
||||
"|---|---|---|---|",
|
||||
"| Codepoint | Name (kebab) | Pascal |",
|
||||
"|---|---|---|",
|
||||
]
|
||||
for r in rows:
|
||||
cp = r["codepoint"]
|
||||
accessor = defined.get(int(cp, 16))
|
||||
in_clide = f"`PhosphorIcons.{accessor}`" if accessor else ""
|
||||
lines.append(f"| `{cp}` | {r['name']} | {r['pascal_name']} | {in_clide} |")
|
||||
lines.append(f"| `{r['codepoint']}` | {r['name']} | {r['pascal_name']} |")
|
||||
|
||||
OUT.write_text("\n".join(lines) + "\n")
|
||||
print(f"wrote {OUT.relative_to(ROOT)} — {len(rows)} glyphs, {n_def} already defined")
|
||||
print(f"wrote {OUT.relative_to(ROOT)} — {len(rows)} glyphs")
|
||||
|
||||
@@ -4,7 +4,8 @@ description: >
|
||||
Surface the best batch of tickets to pick up next from pql. Walks the
|
||||
initiative/epic tree, filters to unblocked work, refines context via
|
||||
parallel agents (or `pql ticket refine` for empty descriptions), and
|
||||
optionally activates the batch on a fresh branch. Use when the user
|
||||
optionally activates the batch (directly on main — no topic branch by
|
||||
default). Use when the user
|
||||
says "what's next", "next batch", "pick up work", or invokes
|
||||
/whats-next. NOT triggered by "what should we work on" in a design
|
||||
context — that's a discussion, not a batch selection.
|
||||
@@ -18,7 +19,8 @@ Dependency-driven batch selection against pql. Three steps:
|
||||
batch selection → refinement review → batch activation.
|
||||
|
||||
Pql is the single source of truth for tickets and decisions in this repo
|
||||
(see [pql skill](../pql/SKILL.md) and [`decisions/README.md`](../../../decisions/README.md)). Always run from the repo root.
|
||||
(see the pql skill — user scope, installed via `pql init` — and
|
||||
[`governance/README.md`](../../../governance/README.md)). Always run from the repo root.
|
||||
|
||||
## Step 0: Sync state
|
||||
|
||||
@@ -158,8 +160,8 @@ Agent({
|
||||
|
||||
Your job:
|
||||
1. Run `pql decisions show <decision_ref> --with-refs --pretty` and
|
||||
read the linked D/Q-record in decisions/<domain>.md.
|
||||
2. Grep decisions/questions-*.md for related Q-records.
|
||||
read the linked D/Q-record in governance/decisions/<domain>.md.
|
||||
2. Grep governance/questions/<domain>.md for related Q-records.
|
||||
3. Verify referenced files, classes, and APIs actually exist in the
|
||||
current tree (Read/Grep). Flag dangling references.
|
||||
4. Cross-check against CLAUDE.md guardrails (single process, CLI-first,
|
||||
@@ -217,24 +219,22 @@ Batch transition (comma-separated IDs):
|
||||
pql ticket status T-1,T-2,T-3 in_progress
|
||||
```
|
||||
|
||||
**Then persist it.** Ticket mutations (status here, and any `ticket new` in
|
||||
Step 2) land only in the gitignored `.pql/pql.db`. The post-checkout/post-merge
|
||||
hooks rebuild that DB from the committed changelog on every branch switch — so
|
||||
un-exported changes vanish silently the next time anyone switches branches. After
|
||||
creating or transitioning tickets, always:
|
||||
|
||||
```bash
|
||||
pql plan export # regenerates .pql/changelog/*.sql
|
||||
git add .pql/changelog && git commit # durable; survives rebuild
|
||||
```
|
||||
|
||||
See the [pql skill](../pql/SKILL.md#versioning-planning-state--data-loss-footgun-read-this).
|
||||
**Then make sure it persists.** Ticket mutations (status here, and any
|
||||
`ticket new` in Step 2) land only in the gitignored `.pql/pql.db`. The
|
||||
pre-commit hook runs `pql plan export --stage` automatically — the changelog
|
||||
is exported and staged on every commit, so never hand-run the export or
|
||||
`git add .pql/changelog`. The rule is simpler: the turn must land at least
|
||||
one commit (through the git-commit skill). A ticket-only turn with no commit
|
||||
leaves the mutations in `pql.db` only, and the post-checkout/post-merge hooks
|
||||
rebuild that DB from the committed changelog on the next branch switch —
|
||||
silently dropping them. See the pql skill's "Versioning planning state"
|
||||
section for the full mechanics.
|
||||
|
||||
### 3b. Branch? Default no.
|
||||
|
||||
Solo-dev flow on this repo — work lands directly on `main` (see recent
|
||||
`git log`). Don't create a topic branch unless the user explicitly asks.
|
||||
If they do, plain `git checkout -b` is fine; there is no `gh` CLI.
|
||||
If they do, plain `git checkout -b` is fine.
|
||||
|
||||
### 3c. Spawn implementation agents (optional)
|
||||
|
||||
@@ -263,13 +263,13 @@ End with a tight summary:
|
||||
## Anti-patterns
|
||||
|
||||
- Don't skip Step 0 — stale `pql.db` makes the rest of the skill lie.
|
||||
- Don't leave ticket changes un-exported — `pql.db` is gitignored and the
|
||||
post-checkout/post-merge hooks rebuild it from the committed changelog, so a
|
||||
branch switch silently drops un-exported tickets. Always `pql plan export` +
|
||||
commit `.pql/changelog/` after mutating tickets (Step 3a).
|
||||
- Don't hand-export or hand-stage `.pql/changelog/` — the pre-commit hook does
|
||||
both on every commit. The real footgun is a turn that mutates tickets but
|
||||
never commits: `pql.db` is gitignored and gets rebuilt from the committed
|
||||
changelog on branch switch, silently dropping un-committed mutations. Land
|
||||
at least one commit per ticket-mutating turn (Step 3a).
|
||||
- Don't activate a batch the user hasn't confirmed.
|
||||
- Don't spawn refinement agents for tickets that have no description — use
|
||||
`pql ticket refine` instead; it's cheaper and writes back through the
|
||||
proper channel.
|
||||
- Don't reach for `gh` — this system doesn't have it. Plain `git` only.
|
||||
- Don't `cd` into subdirectories — run everything from the repo root.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', NULL, '2026-07-02 06:35:03', '2026-07-02 06:35:03.555', '2026-07-02 06:35:03.555', NULL, '33a6fd1c81f2e1142d15ce5791ce3b24', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
|
||||
|
||||
PROGRESS (2026-07-02, session 2): app-integration phase — 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) — pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) — loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) — graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).', NULL, '2026-07-02 08:33:27', '2026-07-02 08:33:27.519', '2026-07-02 08:33:27.519', NULL, '65090e3a71c9f5f49b8070a112216210', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'status', 'backlog', 'done', NULL, '2026-07-02 09:55:52', '2026-07-02 09:55:52.114', '2026-07-02 09:55:52.114', NULL, 'a417eec1fa067a31e46b21578a6228fb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'description', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
|
||||
|
||||
PROGRESS (2026-07-02, session 2): app-integration phase — 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) — pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) — loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) — graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
|
||||
|
||||
PROGRESS (2026-07-02, session 2): app-integration phase — 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) — pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) — loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) — graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).
|
||||
|
||||
COMPLETE (2026-07-02, session 2). All scope delivered + tested (56 graph tests, make test green, real-boot verified via testmode). Slices this session: GraphController (3b67725b), pane shell (6fae1bc4), extension+i18n registration (a5acb6ac), pan+zoom (e6556401), testmode coverage (46d9b403), filter model (f859173b), filter bar (aabec87d). Final feature: force-directed vault graph in the context panel (graph.view, D-47) — hover-highlight, click-to-open, scroll-zoom, drag-pan, and filters (path glob re-queries pql; depth-from-active local graph; tri-state tag include/exclude). Controller uses pql.meta (outlinks+tags in one call) and strips #heading fragments so heading links connect. NOTE: pql data load is 1 files + N meta calls (sequential); batching is a future optimisation if large vaults feel slow.', NULL, '2026-07-02 19:47:37', '2026-07-02 19:47:37.694', '2026-07-02 19:47:37.694', NULL, 'a9f6b8b38858b687f106d1bda236821d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'status', 'in_progress', 'done', NULL, '2026-07-02 19:47:44', '2026-07-02 19:47:44.357', '2026-07-02 19:47:44.357', NULL, 'c8bf520d62290050e6d0feb4a9a6a68d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', NULL, '2026-07-02 20:27:03', '2026-07-02 20:27:03.493', '2026-07-02 20:27:03.493', NULL, '7a661d8cc999f526e26cb1a14b7839c4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'description', NULL, 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.', NULL, '2026-07-02 20:31:02', '2026-07-02 20:31:02.807', '2026-07-02 20:31:02.807', NULL, '5030849d297f6312073802a0641a2594', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'status', 'backlog', 'in_progress', NULL, '2026-07-02 20:35:12', '2026-07-02 20:35:12.739', '2026-07-02 20:35:12.739', NULL, 'd971e23e05c7547672b6989ca0a98434', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'description', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
|
||||
|
||||
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) — slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) — the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.', NULL, '2026-07-02 20:39:06', '2026-07-02 20:39:06.684', '2026-07-02 20:39:06.684', NULL, '02ea6e2dbb60929aed687275f5329a4f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'description', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
|
||||
|
||||
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) — slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) — the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
|
||||
|
||||
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) — slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) — the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.
|
||||
|
||||
SCOPE (do not narrow): the local-command-stdout rendering defect above is ONE symptom, not the whole ticket. The core problem is that after switching model or effort the user has no reliable, well-placed signal that it took effect — so this stays a broad investigate-and-improve of the switch/toggle trust UX, with the render bug as just the first concrete instance. Other symptoms/questions to investigate (non-exhaustive): does the control itself (Config tab picker / effort control / any menu) visibly update to the NEW active value after a switch, or does it look unchanged? is there ANY acknowledgement at the point of interaction, or only (mangled) output buried in the transcript far from where the user clicked? do model vs effort behave consistently, or differently? can clide''s shown ''active'' value drift from what actually took effect (e.g. a switch that silently no-ops)? is the confirmation positioned where the eye already is? The deliverable is: across all these controls, a consistent and unmistakable ''this is now active'' — fixing the render path is necessary but not sufficient. Enumerate the full symptom set during investigation before designing the fix.', NULL, '2026-07-02 20:41:13', '2026-07-02 20:41:13.402', '2026-07-02 20:41:13.402', NULL, '1c7d99c842cc857d5978d91e5b23ab8b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).', NULL, '2026-07-02 21:22:59', '2026-07-02 21:22:59.395', '2026-07-02 21:22:59.395', NULL, '6b17245b0d3d39a0d61bc90a2a0a034b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
|
||||
|
||||
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.', NULL, '2026-07-03 20:20:00', '2026-07-03 20:20:00.827', '2026-07-03 20:20:00.827', NULL, '541312e6b1f559f60934ad1a1b784d76', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
|
||||
|
||||
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
|
||||
|
||||
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.
|
||||
|
||||
PROGRESS (2026-07-05): REACHABLE-INTEGRATION PHASE COMPLETE (e2e305de). CanvasExtension is a real workspace pane: app-scoped MultitabController on the extension (diff/T-233 pattern) with real per-document sub-tabs (MultitabPane keepAlive, per refinement decision), CanvasPaneHost + CanvasDocumentTab (files.read -> CanvasDoc.parse -> CanvasView; loading/error states), routing wired (openWorkspaceFile .canvas branch; ui.open ''canvas'' reader so clide ui open canvas <path> works; fileGlobs left dead as decided), en_us+nl_nl catalogs, json_canvas doc header corrected. Tests: extension lifecycle (7), pane-host widget (6), file_open routing (5, new file), ui_command canvas case; testmode extension roster now includes builtin.canvas (26/26 real-boot pass). REMAINING (edit phase, fresh session): node drag + resize, add note/text/edge affordances, persist CanvasDoc back to disk — encode() exists; CanvasView needs an onChanged/mutation path (refinement note item 5).', NULL, '2026-07-05 07:29:15', '2026-07-05 07:29:15.452', '2026-07-05 07:29:15.452', NULL, '0d0ff6c05b550d4a85296eb38adc830f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'status', 'backlog', 'in_progress', NULL, '2026-07-05 09:52:54', '2026-07-05 09:52:54.013', '2026-07-05 09:52:54.013', NULL, '59efe4bd0a098bfea3f146af21b0caf3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'status', 'in_progress', 'done', NULL, '2026-07-05 10:54:23', '2026-07-05 10:54:23.532', '2026-07-05 10:54:23.532', NULL, '941d08bde0cc4479860e015aded3d664', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'status', 'backlog', 'in_progress', NULL, '2026-07-13 15:17:17', '2026-07-13 15:17:17.537', '2026-07-13 15:17:17.537', NULL, 'fb951233b5fc242889cae1156dc1d0d8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'description', NULL, 'Implements D-106 (FR-1 + worktree addendum). Storage: user-scope key app.env.pathPrepend.<fnv1a64Hex(canonicalWorkspaceKey(presetRoot))> in ~/.config/clide/settings.yaml — never committed; presetRootFor() resolves a linked-worktree workspace (gitdir: pointer file, e.g. .worktrees/<name>) to the main repo root so all worktrees share the repo''s preset.
|
||||
|
||||
Injection (prepend, dedup, next-spawn semantics): PTY panes via PaneRegistry.pathForSpawn hook (main.dart wires the preset-aware closure); hosted Claude via agentEnvDelta(prependDirs:) — now exports PATH whenever a preset exists even if clide is already on PATH (previous gap) — threaded orchestrator.pathPresetFor ← claude extension ← ctx.settings. Toolchain/supporter-binary resolution deliberately preset-blind (T-98/D-92 fence).
|
||||
|
||||
CLI (D-6): one cmd id env.path → clide env path list|set|add|remove|clear|capture; Flutter-free handler lib/src/daemon/env_path_commands.dart over an injected PathPresetStore port (adapter in main.dart); mutations publish on envPathChannel; capture diffs loginShellPathOrNull() (new shell_env getter) vs process PATH. UI: Settings → Tools → Workspace PATH section, custom control (ordered list, add/remove/reorder, missing-dir warning, worktree note, capture suggestions) in builtin.tools-settings; en+nl catalogs.
|
||||
|
||||
.worktrees/ added to IgnoreSet.builtin() and the project-init gitignore scaffold. Tests: path_preset_test (pure fns + real worktree layout), env_path_commands_test (all verbs), agent_bootstrap prependDirs cases, PaneRegistry pathForSpawn PTY test, widget tests for the control incl. worktree key sharing + capture. make test (4048) + make test-a11y green.', NULL, '2026-07-13 15:39:31', '2026-07-13 15:39:31.309', '2026-07-13 15:39:31.309', NULL, '44007d87d28b52050716cd913009d341', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'status', 'in_progress', 'done', NULL, '2026-07-13 16:48:56', '2026-07-13 16:48:56.833', '2026-07-13 16:48:56.833', NULL, 'd6c02324e60b8d412840b1ffd600a13f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNTF7KE5Y497PQC7SCFGR1KC', 'status', 'backlog', 'done', NULL, '2026-07-13 20:49:17', '2026-07-13 20:49:17.282', '2026-07-13 20:49:17.282', NULL, 'd2d3b437c1063f835e5b34c0cd996f9d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'T-507', '2026-07-02 09:41:05.241', '2026-07-02 09:41:05.241', NULL, 'd9dec9b9ed45db65467318f53eb05157', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D1M6ESSSQ1ZERTH4V9YN4', 'T-508', '2026-07-02 09:41:14.425', '2026-07-02 09:41:14.425', NULL, '5389a964251dc22408c41ceac6954f4d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'T-509', '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.783', NULL, 'ea835aca1960e1bee3d0710be19b8bba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'T-510', '2026-07-05 09:52:40.384', '2026-07-05 09:52:40.384', NULL, '6acffc225f49858f782c8c5e801e1c24', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'T-511', '2026-07-13 15:17:10.276', '2026-07-13 15:17:10.276', NULL, 'ffc35cbc7ffe3577e9f103ce6b411857', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNTF7KE5Y497PQC7SCFGR1KC', 'T-512', '2026-07-13 20:48:23.414', '2026-07-13 20:48:23.414', NULL, '90218f8881c60ea87ee29cdfc4e4bdb3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
@@ -0,0 +1,101 @@
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 06:35:03.555', NULL, 'a46ad64b03bc1b4126e9882ed7b81a3a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
|
||||
|
||||
PROGRESS (2026-07-02, session 2): app-integration phase — 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) — pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) — loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) — graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 08:33:27.518', NULL, 'dc254ed9b53362c45d11ac23f600b57d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'task', NULL, 'Skill cleanup: apply 2026-07-02 estate-review fixes to clide repo + user scope', 'Apply verified findings from the estate-wide skill review: delete commit/penpot-login/clean-house(project copy) skills; centralize d2-diagram/frame0-wireframe/skill-create to user scope de-contaminated; repoint whats-next (governance/, gh, dead links, step 3a); fix git-commit heredoc guidance + stale sidecar/ADR refs; fix clide skill dock slot + description trim; testmode terminal category; ui-design phosphor glyph reference post-T-314; rewrite skills README index.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 09:41:05.239', '2026-07-02 09:41:05.239', NULL, 'c5ddebbb4ffe8611329732087dd1cebb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D1M6ESSSQ1ZERTH4V9YN4', 'task', NULL, 'Distribute the clide skill per-workspace with an install marker', 'Estate review 2026-07-02: the clide skill is repo-agnostic and hand-copied into other repos (settled-reach), drifting. Long-term story: clide installs the skill into each workspace''s .claude/skills/ with a .pql-install.json-style marker (version + sha256) so staleness is detectable, following pql''s clean-house precedent. Until then the canonical copy lives in the clide repo.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 09:41:14.420', '2026-07-02 09:41:14.420', NULL, '698b529e4ea8e4417ddb3b9846b30c13', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'task', NULL, 'Skill cleanup: apply 2026-07-02 estate-review fixes to clide repo + user scope', 'Apply verified findings from the estate-wide skill review: delete commit/penpot-login/clean-house(project copy) skills; centralize d2-diagram/frame0-wireframe/skill-create to user scope de-contaminated; repoint whats-next (governance/, gh, dead links, step 3a); fix git-commit heredoc guidance + stale sidecar/ADR refs; fix clide skill dock slot + description trim; testmode terminal category; ui-design phosphor glyph reference post-T-314; rewrite skills README index.', 'done', 'medium', NULL, NULL, NULL, '2026-07-02 09:41:05.239', '2026-07-02 09:55:52.109', NULL, '7470547f02b1269981faefe053aa4ae3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
|
||||
|
||||
PROGRESS (2026-07-02, session 2): app-integration phase — 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) — pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) — loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) — graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).
|
||||
|
||||
COMPLETE (2026-07-02, session 2). All scope delivered + tested (56 graph tests, make test green, real-boot verified via testmode). Slices this session: GraphController (3b67725b), pane shell (6fae1bc4), extension+i18n registration (a5acb6ac), pan+zoom (e6556401), testmode coverage (46d9b403), filter model (f859173b), filter bar (aabec87d). Final feature: force-directed vault graph in the context panel (graph.view, D-47) — hover-highlight, click-to-open, scroll-zoom, drag-pan, and filters (path glob re-queries pql; depth-from-active local graph; tri-state tag include/exclude). Controller uses pql.meta (outlinks+tags in one call) and strips #heading fragments so heading links connect. NOTE: pql data load is 1 files + N meta calls (sequential); batching is a future optimisation if large vaults feel slow.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 19:47:37.694', NULL, '6ef9144fd880038d5db1d318925a9afd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.
|
||||
|
||||
PROGRESS (2026-06-29): force-directed layout SOLVER built + tested (lib/src/graph/force_layout.dart, 6 dart-test cases, commit bcef3a6f) — deterministic Fruchterman-Reingold, Flutter-free. REMAINING (the bulk, multi-session): graph data model from pql backlinks/outlinks over the vault; CustomPaint rendering (nodes+edges); pan/zoom; hover-highlight-subgraph; click-opens-note; filter pane (tag/glob/depth); MultitabPane + context-panel slot (D-47) wiring; widget/golden tests. This is a full interactive Tier-5 pane, a fresh-session undertaking — only its solver core is done.
|
||||
|
||||
PROGRESS (2026-07-02): the graph rendering + interaction foundation is BUILT + tested (4 slices, all committed). (1) force-directed solver — force_layout.dart (bcef3a6f). (2) link-graph model — vault_graph.dart: VaultGraph.fromOutlinks, neighborhood, edgePairs (084bf842). (3) CustomPaint renderer — graph_painter.dart: edges/nodes/labels, hover-dim, fit transform (6bea1723). (4) interactive GraphView — graph_view.dart: hover-highlight + click-to-open, shared GraphViewport hit-testing (c529703b). The graph is a fully working WIDGET (renders + hover + click). REMAINING = the app-integration phase: pql link-data wiring (pql outlinks over the vault -> the file->outlinks map -> VaultGraph), the pane shell (async load + filter: tag/glob/depth + pan/zoom), and MultitabPane + context-panel slot (D-47) + GraphExtension registration.
|
||||
|
||||
PROGRESS (2026-07-02, session 2): app-integration phase — 3 slices committed. (1) GraphController (graph_controller.dart, 3b67725b) — pql.files + per-file pql.outlinks over the vault -> VaultGraph; loading/error state; debounced files.changed refresh; 6 tests. (2) GraphPanel shell (graph_panel.dart, 6fae1bc4) — loading/empty/error states + GraphView + click-to-open via editor.open; 5 widget tests. (3) GraphExtension registration (extension.dart + builtin.graph i18n, a5acb6ac) — graph.view context-panel tab (D-47), activates after builtin.pql dep; 4 tests; CHANGELOG entry. The graph is now REACHABLE end-to-end (21 graph tests green). REMAINING (T-323 enrichment): pan/zoom on the view; filter controls (tag include/exclude, glob, depth-from-active).
|
||||
|
||||
COMPLETE (2026-07-02, session 2). All scope delivered + tested (56 graph tests, make test green, real-boot verified via testmode). Slices this session: GraphController (3b67725b), pane shell (6fae1bc4), extension+i18n registration (a5acb6ac), pan+zoom (e6556401), testmode coverage (46d9b403), filter model (f859173b), filter bar (aabec87d). Final feature: force-directed vault graph in the context panel (graph.view, D-47) — hover-highlight, click-to-open, scroll-zoom, drag-pan, and filters (path glob re-queries pql; depth-from-active local graph; tri-state tag include/exclude). Controller uses pql.meta (outlinks+tags in one call) and strips #heading fragments so heading links connect. NOTE: pql data load is 1 files + N meta calls (sequential); batching is a future optimisation if large vaults feel slow.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-07-02 19:47:44.357', NULL, '4d534c5ebbc8245ac9cdf1c41ddfca06', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-02 20:27:03.493', NULL, '163772c5b568c9fb0b69715648555c30', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.783', NULL, '931821336de8bd9ca5e77cd62f3ca1d3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.806', NULL, 'b4e300bee467ba284a30772cb7a186bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-02 20:35:12.739', NULL, '3e2c84564d3b6cf59f8c8f0e8af1b0b5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
|
||||
|
||||
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) — slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) — the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:39:06.684', NULL, '8678946b438ae5cbdff677bb73949374', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Model switch + effort toggle: unclear UI feedback', 'REPORTED (2026-07-02, Jeroen): can''t trust that the model switch and the effort toggle actually took effect when used — the UI feedback + positioning leave it uncertain whether the change applied. INVESTIGATE: what confirmation (if any) fires today when the model or effort is changed, and where it surfaces relative to the control. Likely-related surfaces: the Config tab controls (T-414), /effort ownership (T-412), account/model settings control. IMPROVE: give an unmistakable, well-positioned confirmation that the change landed — e.g. the control reflects the new value immediately (selected state), and/or a brief toast/inline acknowledgement near the control, not somewhere the eye isn''t. Goal: after a switch, the user is certain which model/effort is now active.
|
||||
|
||||
EVIDENCE + ROOT CAUSE (2026-07-02, from a /model screenshot). The confirmation renders as a raw, unstyled line: literally ''<local-command-stdout>Set model to claude-fable-5[1m] (claude-fable-5)</local-command-stdout>'', shown under the ''you'' (user) speaker stripe. Three distinct defects in that one line: (a) the CLI''s <local-command-stdout> wrapper tag is displayed verbatim instead of being unwrapped; (b) an ANSI SGR bold code leaks through as literal ''[1m]'' (a \x1b[1m) — slash-command stdout is not ANSI-stripped; (c) it is attributed to the USER speaker, so a system/command acknowledgement masquerades as something the user typed. ROOT CAUSE: clide has NO handling for local-command-stdout at all (grep in lib/ = zero hits) — the slash-command result falls through and is rendered as plain user prose. ENTRY POINTS for the fix: inbound message parse / role attribution in lib/builtin/claude/src/transcript_reader.dart; existing ANSI-strip refs in transcript_reader.dart + claude_composer.dart. IMPROVE: detect local-command-stdout content, unwrap the tag + strip ANSI, and render it as a distinct, clearly-positioned system/command-acknowledgement (NOT the user stripe) so a /model or /effort change is an unmistakable confirmation. Affects all slash-command stdout (model, effort, etc.), so fix at the render/parse path, not per-command.
|
||||
|
||||
SCOPE (do not narrow): the local-command-stdout rendering defect above is ONE symptom, not the whole ticket. The core problem is that after switching model or effort the user has no reliable, well-placed signal that it took effect — so this stays a broad investigate-and-improve of the switch/toggle trust UX, with the render bug as just the first concrete instance. Other symptoms/questions to investigate (non-exhaustive): does the control itself (Config tab picker / effort control / any menu) visibly update to the NEW active value after a switch, or does it look unchanged? is there ANY acknowledgement at the point of interaction, or only (mangled) output buried in the transcript far from where the user clicked? do model vs effort behave consistently, or differently? can clide''s shown ''active'' value drift from what actually took effect (e.g. a switch that silently no-ops)? is the confirmation positioned where the eye already is? The deliverable is: across all these controls, a consistent and unmistakable ''this is now active'' — fixing the render path is necessary but not sufficient. Enumerate the full symptom set during investigation before designing the fix.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-02 20:31:02.783', '2026-07-02 20:41:13.402', NULL, '9c7b3632bd0846533a1b65cc12b8145a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-02 21:22:59.395', NULL, '6ba2a2c1892dbf56c582f83bd9753314', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
|
||||
|
||||
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-03 20:20:00.827', NULL, '34eeab12879a55e77e6c9841a442cf27', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FHAX7FV5KWGZQ31617R63W94', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
|
||||
|
||||
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).
|
||||
|
||||
PROGRESS (2026-07-02): two foundation slices built + tested (from the 17-line stub). (1) JSONCanvas parser/model — lib/src/canvas/json_canvas.dart (2eb5d0f1): typed CanvasDoc (text/file/link/group nodes + edges with sides/ends/color/label), parse + toJson round-trip, skips unknown/malformed; pure Dart, 8 dart-test cases. (2) CustomPaint renderer — lib/builtin/canvas/src/canvas_painter.dart (035a37a1): CanvasBounds + pan/zoom CanvasViewport, group frames + edges (arrowheads) + node cards; node colours from the file (Obsidian presets/hex, NOT clide tokens per arbitrary-content rule), chrome themed; 7 tests (pixel-probe + viewport + colour). DECISION NOTE: the interactive pane paints the CanvasDoc model directly via CustomPaint (hit-testable, cheap re-paint) rather than re-lowering to SVG each frame — the ticket''s ''convert to drawing-card JSON'' path (D-91 SVG substrate) is for the display-only viewer; an editable pane needs direct model painting. Mirrors the graph pane. REMAINING (multi-session): interactive CanvasView (hit-test nodes + pan/zoom + click-select), pane shell (load .canvas from disk, workspace slot D-47, MultitabPane), CanvasExtension registration + open-.canvas routing, then edit affordances (add node/edge, drag, resize) + persist back to disk.
|
||||
|
||||
PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive CanvasView (lib/builtin/canvas/src/canvas_view.dart, 7700fc7a): hitTestCanvasNode (topmost node under a point; cards beat the group frames behind, via the painter''s own viewport) + scroll-zoom + drag-pan + click-select with a focus ring; 6 tests. The canvas rendering + interaction FOUNDATION is now complete + tested (parser 2eb5d0f1, renderer 035a37a1, view 7700fc7a — 21 canvas tests), mirroring where T-323''s foundation was before its integration phase. NOT yet reachable. REACHABLE-INTEGRATION PHASE (next, fresh session): (a) pane shell — a StatefulWidget that loads a .canvas from disk (files.read/editor.open path), holds the CanvasDoc, embeds CanvasView; (b) CanvasExtension registration as a workspace pane (own slot D-47, MultitabPane T-83); (c) FILE-OPEN ROUTING — how opening a .canvas lands in this pane instead of the text editor: NOTE the TabContribution.fileGlobs field is DECLARED but has ZERO consumers in lib/, so this routing must be built (wire fileGlobs, or have the editor delegate by extension, or a command + workspace tab); (d) i18n + tests. THEN the edit phase: add node/text/edge affordances, node drag + resize, and persist the CanvasDoc back to the .canvas file (encode() already exists).
|
||||
|
||||
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.
|
||||
|
||||
PROGRESS (2026-07-05): REACHABLE-INTEGRATION PHASE COMPLETE (e2e305de). CanvasExtension is a real workspace pane: app-scoped MultitabController on the extension (diff/T-233 pattern) with real per-document sub-tabs (MultitabPane keepAlive, per refinement decision), CanvasPaneHost + CanvasDocumentTab (files.read -> CanvasDoc.parse -> CanvasView; loading/error states), routing wired (openWorkspaceFile .canvas branch; ui.open ''canvas'' reader so clide ui open canvas <path> works; fileGlobs left dead as decided), en_us+nl_nl catalogs, json_canvas doc header corrected. Tests: extension lifecycle (7), pane-host widget (6), file_open routing (5, new file), ui_command canvas case; testmode extension roster now includes builtin.canvas (26/26 real-boot pass). REMAINING (edit phase, fresh session): node drag + resize, add note/text/edge affordances, persist CanvasDoc back to disk — encode() exists; CanvasView needs an onChanged/mutation path (refinement note item 5).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-05 07:29:15.447', NULL, '1be84ba4d4357171ef329f39b601f4e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 09:52:40.379', NULL, 'ec1eed07c9a69b4edbf3f4ffe671f696', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 09:52:54.013', NULL, '56204043cd686ca0fcae06db735b72ef', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'done', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 10:54:23.532', NULL, '143695c5eefa42dab2c05edcb06a4427', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', NULL, 'backlog', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 15:17:10.275', NULL, 'eb6abfb644234fe0c73b4785efcdad16', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', NULL, 'in_progress', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 15:17:17.532', NULL, '6e4b7347d0e854ea4c5fe4b394c61412', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', 'Implements D-106 (FR-1 + worktree addendum). Storage: user-scope key app.env.pathPrepend.<fnv1a64Hex(canonicalWorkspaceKey(presetRoot))> in ~/.config/clide/settings.yaml — never committed; presetRootFor() resolves a linked-worktree workspace (gitdir: pointer file, e.g. .worktrees/<name>) to the main repo root so all worktrees share the repo''s preset.
|
||||
|
||||
Injection (prepend, dedup, next-spawn semantics): PTY panes via PaneRegistry.pathForSpawn hook (main.dart wires the preset-aware closure); hosted Claude via agentEnvDelta(prependDirs:) — now exports PATH whenever a preset exists even if clide is already on PATH (previous gap) — threaded orchestrator.pathPresetFor ← claude extension ← ctx.settings. Toolchain/supporter-binary resolution deliberately preset-blind (T-98/D-92 fence).
|
||||
|
||||
CLI (D-6): one cmd id env.path → clide env path list|set|add|remove|clear|capture; Flutter-free handler lib/src/daemon/env_path_commands.dart over an injected PathPresetStore port (adapter in main.dart); mutations publish on envPathChannel; capture diffs loginShellPathOrNull() (new shell_env getter) vs process PATH. UI: Settings → Tools → Workspace PATH section, custom control (ordered list, add/remove/reorder, missing-dir warning, worktree note, capture suggestions) in builtin.tools-settings; en+nl catalogs.
|
||||
|
||||
.worktrees/ added to IgnoreSet.builtin() and the project-init gitignore scaffold. Tests: path_preset_test (pure fns + real worktree layout), env_path_commands_test (all verbs), agent_bootstrap prependDirs cases, PaneRegistry pathForSpawn PTY test, widget tests for the control incl. worktree key sharing + capture. make test (4048) + make test-a11y green.', 'in_progress', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 15:39:31.309', NULL, 'c5a978f32c7497e478c471069ed20b7c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNR3DP0D48AZZ3NRA8JF3Q10', 'task', NULL, 'Per-workspace PATH preset: settings + CLI (env.path) + spawn injection, worktree-aware (FR-1)', 'Implements D-106 (FR-1 + worktree addendum). Storage: user-scope key app.env.pathPrepend.<fnv1a64Hex(canonicalWorkspaceKey(presetRoot))> in ~/.config/clide/settings.yaml — never committed; presetRootFor() resolves a linked-worktree workspace (gitdir: pointer file, e.g. .worktrees/<name>) to the main repo root so all worktrees share the repo''s preset.
|
||||
|
||||
Injection (prepend, dedup, next-spawn semantics): PTY panes via PaneRegistry.pathForSpawn hook (main.dart wires the preset-aware closure); hosted Claude via agentEnvDelta(prependDirs:) — now exports PATH whenever a preset exists even if clide is already on PATH (previous gap) — threaded orchestrator.pathPresetFor ← claude extension ← ctx.settings. Toolchain/supporter-binary resolution deliberately preset-blind (T-98/D-92 fence).
|
||||
|
||||
CLI (D-6): one cmd id env.path → clide env path list|set|add|remove|clear|capture; Flutter-free handler lib/src/daemon/env_path_commands.dart over an injected PathPresetStore port (adapter in main.dart); mutations publish on envPathChannel; capture diffs loginShellPathOrNull() (new shell_env getter) vs process PATH. UI: Settings → Tools → Workspace PATH section, custom control (ordered list, add/remove/reorder, missing-dir warning, worktree note, capture suggestions) in builtin.tools-settings; en+nl catalogs.
|
||||
|
||||
.worktrees/ added to IgnoreSet.builtin() and the project-init gitignore scaffold. Tests: path_preset_test (pure fns + real worktree layout), env_path_commands_test (all verbs), agent_bootstrap prependDirs cases, PaneRegistry pathForSpawn PTY test, widget tests for the control incl. worktree key sharing + capture. make test (4048) + make test-a11y green.', 'done', 'high', NULL, NULL, 'D-106', '2026-07-13 15:17:10.275', '2026-07-13 16:48:56.833', NULL, '09c6b262b3c0ecfd2f621d27f2cc5eb2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNTF7KE5Y497PQC7SCFGR1KC', 'task', NULL, 'Lead the clide skill description with clide capabilities + env mechanics (FR-2)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-07-13 20:48:23.409', '2026-07-13 20:48:23.409', NULL, '28edb12dfa1f2bf75d108f6cf9465ea5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FNTF7KE5Y497PQC7SCFGR1KC', 'task', NULL, 'Lead the clide skill description with clide capabilities + env mechanics (FR-2)', NULL, 'done', 'low', NULL, NULL, NULL, '2026-07-13 20:48:23.409', '2026-07-13 20:49:17.282', NULL, 'b7eaec1d037d1c36cf54fac07b3b2dd4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
@@ -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.*
|
||||
@@ -22,6 +22,152 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
## [2.10.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Canvas pane.** `.canvas` (Obsidian JSONCanvas) files now open in an
|
||||
interactive workspace pane — each document a sub-tab, with pan, zoom, and
|
||||
node selection. Routed from the file tree, quick-open, and
|
||||
`clide ui open canvas <path>`. (T-322)
|
||||
- **Vault graph view.** A force-directed link graph of the whole vault in the
|
||||
context panel — notes are nodes, wikilinks edges. Hover highlights a note's
|
||||
neighbourhood; click opens it. Scroll to zoom, drag to pan; filter by path
|
||||
glob, tag include/exclude, or depth from the active note. (T-323)
|
||||
- **Per-workspace PATH preset.** Directories prepended to the PATH of every
|
||||
shell clide spawns for a repo — Claude sessions and terminal panes. Settings
|
||||
→ Tools → Workspace PATH, or `clide env path …` with capture-from-login-shell
|
||||
suggestions. Worktrees share their repo's preset; stored machine-local, never
|
||||
committed. (D-106, T-511)
|
||||
|
||||
### Changed
|
||||
|
||||
- Permission mode: Ctrl/Cmd+Shift+M cycles the full list including
|
||||
bypassPermissions, and the composer menu's bypass row unlocks via
|
||||
shift-click — plain Ctrl/Cmd+M and plain clicks stay safe-trio only (T-510).
|
||||
- Bundled Claude skills pruned to clide-specific ones (T-507): broken/duplicate
|
||||
skills removed, repo-agnostic ones moved to user scope, and stale guidance in
|
||||
the remaining five (paths, commands, icon API) brought back in line with the
|
||||
repo.
|
||||
- The `clide` skill description now leads with the hosting mechanics (`clide`
|
||||
on PATH, `CLIDE_SOCK`) and `clide capabilities` as the entry action, so an
|
||||
agent deciding whether to invoke it sees the first move up front (T-512).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hash-keyed settings (per-workspace PATH presets, Claude account bindings)
|
||||
with a number-shaped hash suffix were silently lost on restart — YAML map
|
||||
keys are now quoted on write.
|
||||
|
||||
## [2.9.0] — 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- **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 (10–48), 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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Canvas" },
|
||||
"empty": { "translation": "Open a .canvas file to view it here." },
|
||||
"status.loading": { "translation": "Loading…" }
|
||||
}
|
||||
@@ -47,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" },
|
||||
@@ -103,7 +106,9 @@
|
||||
"tool.name.TodoWrite": { "translation": "TodoWrite" },
|
||||
"tool.name.ExitPlanMode": { "translation": "ExitPlanMode" },
|
||||
"permissionControl.semantics": { "translation": "permission mode: {mode}. Activate to change." },
|
||||
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles)" },
|
||||
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)" },
|
||||
"permissionControl.bypassHint": { "translation": "shift-click" },
|
||||
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-click to enable" },
|
||||
"permissionBadge.tooltip": { "translation": "Permission mode: {mode}. Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions." },
|
||||
"permissionBadge.semantics": { "translation": "Permission mode: {label}" },
|
||||
"card.expand": { "translation": "Expand" },
|
||||
@@ -237,5 +242,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." }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tab.graph.title": { "translation": "Graph" },
|
||||
"graph.empty": { "translation": "No linked notes in this vault." },
|
||||
"graph.empty.filtered": { "translation": "No notes match the filter." },
|
||||
"graph.empty.nolocal": { "translation": "Open a note to see its local graph." },
|
||||
"graph.filter.glob": { "translation": "Path glob, e.g. notes/**" },
|
||||
"graph.filter.depth": { "translation": "Local" },
|
||||
"graph.filter.all": { "translation": "All" }
|
||||
}
|
||||
@@ -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,26 @@
|
||||
{
|
||||
"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." },
|
||||
"settings.section.path": { "translation": "Workspace PATH" },
|
||||
"settings.field.pathPreset.label": { "translation": "Prepend to PATH" },
|
||||
"settings.field.pathPreset.help": { "translation": "Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo's preset. Applies to new shells." },
|
||||
"path.noWorkspace": { "translation": "Open a workspace to set its PATH preset." },
|
||||
"path.sharedRoot": { "translation": "Worktree — preset shared with" },
|
||||
"path.empty": { "translation": "No preset entries — spawned shells get the resolved login-shell PATH as-is." },
|
||||
"path.add": { "translation": "Add entry" },
|
||||
"path.invalid": { "translation": "Enter an absolute path (or ~/…)." },
|
||||
"path.missing": { "translation": "missing" },
|
||||
"path.moveUp": { "translation": "Move up" },
|
||||
"path.moveDown": { "translation": "Move down" },
|
||||
"path.remove": { "translation": "Remove" },
|
||||
"path.capture": { "translation": "Suggest from login shell" },
|
||||
"path.captureNone": { "translation": "Nothing to suggest — the login-shell PATH is already covered." },
|
||||
"path.addSuggestion": { "translation": "Add suggested entry" }
|
||||
}
|
||||
@@ -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…" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Canvas" },
|
||||
"empty": { "translation": "Open een .canvas-bestand om het hier te bekijken." },
|
||||
"status.loading": { "translation": "Laden…" }
|
||||
}
|
||||
@@ -47,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" },
|
||||
@@ -103,7 +106,9 @@
|
||||
"tool.name.TodoWrite": { "translation": "Takenlijst" },
|
||||
"tool.name.ExitPlanMode": { "translation": "Planmodus verlaten" },
|
||||
"permissionControl.semantics": { "translation": "permissiemodus: {mode}. Activeer om te wijzigen." },
|
||||
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt)" },
|
||||
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt; +Shift ook bypass)" },
|
||||
"permissionControl.bypassHint": { "translation": "shift-klik" },
|
||||
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-klik om in te schakelen" },
|
||||
"permissionBadge.tooltip": { "translation": "Permissiemodus: {mode}. Klik om te wisselen tussen default/acceptEdits/plan; Shift-klik voor bypassPermissions." },
|
||||
"permissionBadge.semantics": { "translation": "Permissiemodus: {label}" },
|
||||
"card.expand": { "translation": "Uitvouwen" },
|
||||
@@ -237,5 +242,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." }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tab.graph.title": { "translation": "Grafiek" },
|
||||
"graph.empty": { "translation": "Geen gekoppelde notities in deze vault." },
|
||||
"graph.empty.filtered": { "translation": "Geen notities voldoen aan het filter." },
|
||||
"graph.empty.nolocal": { "translation": "Open een notitie om de lokale grafiek te zien." },
|
||||
"graph.filter.glob": { "translation": "Padpatroon, bijv. notes/**" },
|
||||
"graph.filter.depth": { "translation": "Lokaal" },
|
||||
"graph.filter.all": { "translation": "Alles" }
|
||||
}
|
||||
@@ -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,26 @@
|
||||
{
|
||||
"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." },
|
||||
"settings.section.path": { "translation": "Workspace-PATH" },
|
||||
"settings.field.pathPreset.label": { "translation": "Vooraan aan PATH toevoegen" },
|
||||
"settings.field.pathPreset.help": { "translation": "Mappen die vóór PATH komen in Claude-sessies en terminalvensters voor deze repo; worktrees delen de preset van de repo. Geldt voor nieuwe shells." },
|
||||
"path.noWorkspace": { "translation": "Open een workspace om de PATH-preset in te stellen." },
|
||||
"path.sharedRoot": { "translation": "Worktree — preset gedeeld met" },
|
||||
"path.empty": { "translation": "Geen preset-items — nieuwe shells krijgen het opgeloste login-shell-PATH ongewijzigd." },
|
||||
"path.add": { "translation": "Item toevoegen" },
|
||||
"path.invalid": { "translation": "Voer een absoluut pad in (of ~/…)." },
|
||||
"path.missing": { "translation": "ontbreekt" },
|
||||
"path.moveUp": { "translation": "Omhoog" },
|
||||
"path.moveDown": { "translation": "Omlaag" },
|
||||
"path.remove": { "translation": "Verwijderen" },
|
||||
"path.capture": { "translation": "Voorstellen uit login-shell" },
|
||||
"path.captureNone": { "translation": "Niets voor te stellen — het login-shell-PATH is al gedekt." },
|
||||
"path.addSuggestion": { "translation": "Voorgesteld item toevoegen" }
|
||||
}
|
||||
@@ -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…" }
|
||||
}
|
||||
|
||||
@@ -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.1"
|
||||
version: "2.10.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
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" }
|
||||
}
|
||||
}
|
||||
|
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" }
|
||||
}
|
||||
}
|
||||
|
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" }
|
||||
}
|
||||
}
|
||||
|
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" }
|
||||
}
|
||||
}
|
||||
|
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" }
|
||||
}
|
||||
}
|
||||
|
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" }
|
||||
}
|
||||
}
|
||||
|
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.
|
||||
|
||||
Here’s 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 clide’s 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 Claude’s line-delimited JSON).
|
||||
- **Tool execution** (native and MCP-based).
|
||||
- **Permission prompts** (stdio-based gating, like Claude’s `can_use_tool`).
|
||||
- **Session resumption** (`--resume` flag).
|
||||
- **Impact**:
|
||||
- **clide’s `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**:
|
||||
- **clide’s `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.
|
||||
- clide’s `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**:
|
||||
- **clide’s 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**:
|
||||
- **clide’s `TranscriptReader` can be adapted** to parse Vibe CLI’s 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**:
|
||||
- **clide’s 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 clide’s 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 | 3–5 days | **1–2 days** | **2–3 days** |
|
||||
| Reimplement permission prompts | 5–7 days | **0 days** (reuse existing) | **5–7 days** |
|
||||
| Reimplement AskUserQuestion | 3–5 days | **0 days** (reuse existing) | **3–5 days** |
|
||||
| Session persistence | 3–5 days | **0 days** (reuse existing) | **3–5 days** |
|
||||
| Update config system | 3–5 days | **1–2 days** (adapt `.vibe/`) | **2–3 days** |
|
||||
| Update team orchestration | 2–3 days | **1–2 days** (reuse MCP broker) | **1 day** |
|
||||
| Testing & debugging | 5–7 days | **3–5 days** | **2 days** |
|
||||
| **Total** | **3–4 weeks** | **1–2 weeks** | **~2 weeks** |
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Updated Recommendations with Vibe CLI**
|
||||
|
||||
### **🟢 Option 1: Direct Vibe CLI Integration (Recommended)**
|
||||
**Approach**: Replace `claude` with `vibe` in clide’s spawn logic and adapt the existing protocol handlers.
|
||||
**Complexity**: **Low-Medium (1–2 weeks)**
|
||||
**Pros**:
|
||||
- **Minimal changes** to clide’s core architecture.
|
||||
- **Full parity** for **stdio protocol, permissions, sessions, and tools**.
|
||||
- **Leverages Vibe CLI’s native features** (MCP, local inference, config).
|
||||
**Cons**:
|
||||
- **Multi-agent teams still require clide’s 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 CLI’s JSONL format** (likely very similar to Claude’s).
|
||||
- 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 Claude’s stream-json protocol **exactly**.
|
||||
**Complexity**: **Low (1 week)**
|
||||
**Pros**:
|
||||
- **Zero changes to clide’s 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 Claude’s format.
|
||||
- **Example**:
|
||||
```bash
|
||||
# Spawn Vibe CLI via wrapper
|
||||
vibe-wrapper --session-id <id> --stdio
|
||||
```
|
||||
2. **Update clide’s 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 Mistral’s raw REST API (as in the original report).
|
||||
**Complexity**: **High (3–4 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%** | clide’s 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 Claude’s. |
|
||||
| **Session resumption bugs** | Low | Medium | Test `--resume` with various session states. |
|
||||
| **MCP tool differences** | Medium | Low | Ensure Vibe CLI’s MCP support aligns with clide’s 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 clide’s existing UI can be reused.
|
||||
3. **Session Persistence**:
|
||||
- Vibe CLI **supports `--resume`**, so clide’s 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 clide’s 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 | **1–2 weeks** | **~95%** | ✅ **Yes (Best Option)** |
|
||||
| **Vibe CLI + Wrapper** | Low | **1 week** | **100%** | ⚠️ **Only if protocol differences exist** |
|
||||
| **Mistral API** | High | **3–4 weeks** | **~70%** | ❌ **Not recommended** |
|
||||
|
||||
### **🚀 Recommended Path Forward**
|
||||
1. **Start with Direct Vibe CLI Integration**:
|
||||
- Replace `claude` with `vibe` in clide’s spawn logic.
|
||||
- Adapt `StreamJsonSession` and `TranscriptReader` for Vibe CLI’s output format.
|
||||
- Update `ClaudeConfig` to watch `.vibe/` instead of `.claude/`.
|
||||
- **Effort**: **1–2 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 CLI’s output.
|
||||
- **Effort**: **1 additional week**.
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Conclusion: Vibe CLI Makes Migration Trivial**
|
||||
|
||||
**Vibe CLI changes the math dramatically**:
|
||||
- **Original (Mistral API)**: **3–4 weeks**, **major reimplementation**, **lost features**.
|
||||
- **With Vibe CLI**: **1–2 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 clide’s 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 (1–2 weeks). Without Vibe CLI (raw Mistral API), it’s a high-effort, partial-parity migration (3–4 weeks). Vibe CLI is the clear winner.**
|
||||
@@ -0,0 +1,25 @@
|
||||
# clide feature requests
|
||||
|
||||
Pre-triage inbox for feature ideas surfaced from consumer use. Accepted
|
||||
items move into the planning dataset (`pql ticket new …`); this file is
|
||||
the scratchpad before that, not a parallel backlog. (Same convention as
|
||||
pql's `feature-request.md`, pointed the other way.)
|
||||
|
||||
---
|
||||
|
||||
## FR-1 — Settings-panel option: per-repo preset PATH *(accepted → D-106 / T-511, 2026-07-13)*
|
||||
|
||||
Moved into the planning dataset: decision
|
||||
[D-106](governance/decisions/tooling.md#d-106-per-workspace-path-preset-prepended-at-spawn)
|
||||
and ticket T-511 (shipped). Kept here as a tombstone per this file's
|
||||
convention; the problem, proposal, and design constraints are captured
|
||||
in the D-record.
|
||||
|
||||
---
|
||||
|
||||
## FR-2 — Lead the `clide` skill `description` with `clide capabilities` *(accepted → T-512, 2026-07-13)*
|
||||
|
||||
Applied: the canonical `.claude/skills/clide/SKILL.md` `description` now
|
||||
leads with the hosting context (`clide` on PATH, `CLIDE_SOCK`) and
|
||||
`clide capabilities` as the entry action, keeping the trigger list.
|
||||
Pure frontmatter wording; no behavior change.
|
||||
@@ -144,6 +144,10 @@ 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_
|
||||
- [D-106: Per-workspace PATH preset prepended at spawn](decisions/tooling.md#d-106-per-workspace-path-preset-prepended-at-spawn) — _tooling_
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
@@ -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,19 @@ 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-92]/[D-59]) through an **explicit `tools:` map in user-scope settings**: tool-name → absolute path. The map holds concrete, user-visible, user-editable paths and is the **first** step in resolution — if an entry is set, clide uses that exact path (honest error if it is missing / not executable, falling **down the chain with a warning** rather than hard-failing). On **first run**, clide auto-detects each tool **once** — probing the login-shell PATH (T-439) plus well-known dirs (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew`, Homebrew-on-Linux `/home/linuxbrew/.linuxbrew/bin`) — and **writes the discovered absolute paths into the map**, so detection is a one-time *populate*, not a per-launch heuristic; thereafter the pinned explicit value wins. A **re-detect** action re-runs the probe (e.g. after installing a tool). Unset/undetected tools fall back to the existing chain (bundled/pinned per [D-92]/[D-59], then login-shell PATH). **User-scope only, keyed by machine ([D-93]) — never committed** (absolute paths are machine-specific). Generalizes [D-92]'s `CLIDE_PQL_BIN` override to every supporter tool; surfaced in the Config tab and the Problems panel for unresolved tools.
|
||||
- **Rationale:** PATH-probing (T-439) is adaptive but brittle on non-standard installs — a login-shell probe misses Homebrew when `brew shellenv` lives only in `~/.bashrc` (login shells source `.bash_profile`/`.profile`, not `.bashrc`). Materializing resolution into explicit, pinned paths makes it deterministic and debuggable: "tool not found" becomes a one-line settings fix, and the value is *visible* rather than recomputed by heuristic each launch. First-run auto-detect keeps it zero-config for standard installs; **pinning the result instead of re-probing is what makes it explicit** — escaping the heuristic fragility while keeping the convenience.
|
||||
- **Cost:** A first-run detection pass plus a small settings surface. Stale pins (a tool moved on a brew upgrade) must fall back + warn, not hard-fail. The map is per-machine, so it does not travel with the repo — each machine detects once.
|
||||
- **Cross-reference:** [D-92](#d-92-ship-pql-bundled-with-clide) (pql resolution + `CLIDE_PQL_BIN` override), [D-59](#d-59-bundled-git-via-dugite-native), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) (user-scope state), T-439 (login-shell-derived PATH), T-494 (the d2 template — first consumer).
|
||||
- **Raised by:** 2026-06-28 — user, during the drawing-card SVG/d2 work after the login-shell PATH probe was seen to miss linuxbrew: "should we just add the hard paths to the supporting binaries to the clide settings file?" + "auto detect on first run sounds solid" — explicit pinned paths, populated by a one-time first-run probe.
|
||||
|
||||
### D-106: Per-workspace PATH preset prepended at spawn
|
||||
- **Date:** 2026-07-13
|
||||
- **Decision:** Each workspace can carry a **preset list of directories prepended to the PATH** clide hands to everything it spawns a shell for: the **hosted Claude session** (so the agent's Bash tool sees it — the [D-83](architecture.md#d-83-dogfood-agent-model--hosted-stream-json-session-primary-external-cli-driver-secondary) bootstrap seam that already injects `CLIDE_SOCK`/`CLIDE_WORKSPACE`) and **terminal PTY panes**. Prepend semantics: preset dirs win over the resolved login-shell PATH (T-439), applied at spawn time — no profile sourcing, no login-shell requirement, and only NEW spawns see an edit. Storage is **user-scope, keyed by workspace hash ([D-93]) — never committed** (`app.env.pathPrepend.<hash>`; absolute paths are machine-specific, and a committed preset would let a cloned repo inject PATH). The hash keys off the **repo identity, not the literal directory**: a workspace that is a linked git worktree (its `.git` is a `gitdir:` pointer file, e.g. under an in-repo `.worktrees/` dir — supported, and gitignored by the scaffold) resolves to the **main repo root**, so every worktree shares its repo's preset. **Security fence:** the preset feeds only spawned-shell environments — clide's own binary resolution (pinned `tools:` per [D-104](#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings), bundled pql/git per [D-92](#d-92-ship-pql-bundled-with-clide)/[D-59](#d-59-bundled-git-via-dugite-native), the toolchain probe) never consults it (the T-98 dugite lesson). Parity ([D-6](architecture.md#d-6-cli-and-event-surface-contract)): `clide env path list|set|add|remove|clear|capture` mirrors the settings-panel editor; `capture` suggests entries by diffing the login-shell PATH against the process PATH — "set once by either party".
|
||||
- **Rationale:** The T-439 login-shell probe is a best-effort global heuristic with a known residual hole — a login-but-non-interactive probe misses interactive-only profile additions (brew `shellenv` in `~/.bashrc`), which is exactly the observed failure (Go toolchain invisible to the agent in the pql repo, 2026-07-08). [D-104] made single binaries explicit; this extends the same explicit-over-heuristic philosophy to whole directories, per repo, visible and editable by both the user and the agent instead of re-discovered per session.
|
||||
- **Cost:** Per-machine — a preset does not travel with the repo and re-keys (resets) when a repo moves ([D-93] trade-off, same as account bindings). One more env layer every spawn site must route through the single `shell_env` resolver; already-running shells keep their env until respawned.
|
||||
- **Cross-reference:** [D-104](#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-83](architecture.md#d-83-dogfood-agent-model--hosted-stream-json-session-primary-external-cli-driver-secondary), [D-6](architecture.md#d-6-cli-and-event-surface-contract), [D-92](#d-92-ship-pql-bundled-with-clide), [D-59](#d-59-bundled-git-via-dugite-native), T-439 (login-shell PATH resolver), T-484 (spawn-time `CLAUDE_CONFIG_DIR` injection — the per-workspace spawn-env precedent).
|
||||
- **Raised by:** 2026-07-08 — FR-1, logged from a pql session where the agent shell could not find the Go toolchain: "a settings-panel option (per repo/workspace): a list of PATH entries to prepend, set once, then injected everywhere clide spawns a shell"; worktree keying added 2026-07-13 — user: "make sure the PATH settings take usage of worktrees inside a .worktrees dir inside the repo into account".
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export 'src/canvas_pane_host.dart';
|
||||
export 'src/extension.dart';
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/// Paints a [CanvasDoc] (T-322): group frames, edges, and node cards fitted
|
||||
/// into the pane with a shared pan/zoom [CanvasViewport] — so hit-testing (a
|
||||
/// later slice) lands exactly on what's drawn.
|
||||
///
|
||||
/// A `.canvas` is arbitrary user content, so node *colours* come from the file
|
||||
/// (Obsidian presets `1`..`6` or `#rrggbb`), NOT clide's theme tokens; only the
|
||||
/// chrome — the surface, default card, selection ring — is themed.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/canvas/json_canvas.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The axis-aligned bounds of a doc's node rects, in canvas coordinates.
|
||||
class CanvasBounds {
|
||||
const CanvasBounds(this.left, this.top, this.right, this.bottom);
|
||||
|
||||
final double left, top, right, bottom;
|
||||
double get width => right - left;
|
||||
double get height => bottom - top;
|
||||
|
||||
factory CanvasBounds.of(CanvasDoc doc) {
|
||||
if (doc.nodes.isEmpty) return const CanvasBounds(0, 0, 1, 1);
|
||||
var l = double.infinity, t = double.infinity, r = double.negativeInfinity, b = double.negativeInfinity;
|
||||
for (final n in doc.nodes) {
|
||||
l = math.min(l, n.x);
|
||||
t = math.min(t, n.y);
|
||||
r = math.max(r, n.x + n.width);
|
||||
b = math.max(b, n.y + n.height);
|
||||
}
|
||||
return CanvasBounds(l, t, r, b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fits a doc's [content] bounds into the pane (aspect-preserving, padded, and
|
||||
/// centred), then applies the user's [zoom] (about the pane centre) and [pan].
|
||||
class CanvasViewport {
|
||||
CanvasViewport(this.scale, this.dx, this.dy);
|
||||
final double scale, dx, dy;
|
||||
|
||||
factory CanvasViewport.fit(Size canvas, CanvasBounds content, {double zoom = 1, Offset pan = Offset.zero, double padding = 24}) {
|
||||
final cw = content.width <= 0 ? 1.0 : content.width;
|
||||
final ch = content.height <= 0 ? 1.0 : content.height;
|
||||
final avw = math.max(1.0, canvas.width - 2 * padding);
|
||||
final avh = math.max(1.0, canvas.height - 2 * padding);
|
||||
final base = math.min(avw / cw, avh / ch);
|
||||
final scale = (base.isFinite && base > 0 ? base : 1.0) * zoom;
|
||||
final dx = canvas.width / 2 + pan.dx - scale * (content.left + cw / 2);
|
||||
final dy = canvas.height / 2 + pan.dy - scale * (content.top + ch / 2);
|
||||
return CanvasViewport(scale, dx, dy);
|
||||
}
|
||||
|
||||
Offset toPixel(double x, double y) => Offset(dx + x * scale, dy + y * scale);
|
||||
|
||||
Rect rectOf(CanvasNode n) => Rect.fromLTWH(dx + n.x * scale, dy + n.y * scale, n.width * scale, n.height * scale);
|
||||
}
|
||||
|
||||
/// The content colour of a node/edge: an Obsidian preset `"1".."6"`, a
|
||||
/// `#rgb`/`#rrggbb` hex, or null when unset. Kept theme-independent on purpose.
|
||||
Color? canvasContentColor(String? spec) {
|
||||
if (spec == null || spec.isEmpty) return null;
|
||||
if (spec.startsWith('#')) return _hex(spec);
|
||||
return switch (spec) {
|
||||
'1' => const Color(0xFFFB464C), // red
|
||||
'2' => const Color(0xFFE9973F), // orange
|
||||
'3' => const Color(0xFFE0DE71), // yellow
|
||||
'4' => const Color(0xFF44CF6E), // green
|
||||
'5' => const Color(0xFF53DFDD), // cyan
|
||||
'6' => const Color(0xFFA882FF), // purple
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// The id of the topmost node under [local], or null. Uses the same
|
||||
/// [CanvasViewport.fit] the painter draws with, so a click lands on what's
|
||||
/// shown. Cards (drawn last) win over the group frames behind them.
|
||||
String? hitTestCanvasNode(CanvasDoc doc, Offset local, Size size, {double zoom = 1, Offset pan = Offset.zero}) {
|
||||
if (doc.isEmpty) return null;
|
||||
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan);
|
||||
for (final n in doc.nodes.reversed) {
|
||||
if (n is! GroupNode && vp.rectOf(n).contains(local)) return n.id;
|
||||
}
|
||||
for (final n in doc.nodes.reversed) {
|
||||
if (n is GroupNode && vp.rectOf(n).contains(local)) return n.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Color? _hex(String s) {
|
||||
var h = s.substring(1);
|
||||
if (h.length == 3) h = h.split('').map((c) => '$c$c').join();
|
||||
if (h.length != 6) return null;
|
||||
final v = int.tryParse(h, radix: 16);
|
||||
return v == null ? null : Color(0xFF000000 | v);
|
||||
}
|
||||
|
||||
class CanvasPainter extends CustomPainter {
|
||||
CanvasPainter({required this.doc, required this.tokens, this.zoom = 1, this.pan = Offset.zero, this.selected});
|
||||
|
||||
final CanvasDoc doc;
|
||||
final SurfaceTokens tokens;
|
||||
final double zoom;
|
||||
final Offset pan;
|
||||
|
||||
/// The id of the currently-selected node, drawn with a focus ring.
|
||||
final String? selected;
|
||||
|
||||
@override
|
||||
void paint(ui.Canvas canvas, Size size) {
|
||||
if (doc.isEmpty) return;
|
||||
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan);
|
||||
final byId = {for (final n in doc.nodes) n.id: n};
|
||||
|
||||
// Groups sit behind everything as translucent framed regions.
|
||||
for (final n in doc.nodes.whereType<GroupNode>()) {
|
||||
_group(canvas, vp, n);
|
||||
}
|
||||
for (final e in doc.edges) {
|
||||
_edge(canvas, vp, e, byId);
|
||||
}
|
||||
for (final n in doc.nodes) {
|
||||
if (n is! GroupNode) _card(canvas, vp, n);
|
||||
}
|
||||
}
|
||||
|
||||
void _group(ui.Canvas canvas, CanvasViewport vp, GroupNode n) {
|
||||
final rect = vp.rectOf(n);
|
||||
final tint = canvasContentColor(n.color) ?? tokens.globalTextMuted;
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(6)), Paint()..color = tint.withValues(alpha: 0.06));
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(6)),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = tint.withValues(alpha: 0.5),
|
||||
);
|
||||
if (n.label != null && n.label!.isNotEmpty) {
|
||||
_text(canvas, n.label!, Offset(rect.left + 6, rect.top + 4), tint, maxWidth: rect.width - 12, size: 11);
|
||||
}
|
||||
}
|
||||
|
||||
void _card(ui.Canvas canvas, CanvasViewport vp, CanvasNode n) {
|
||||
final rect = vp.rectOf(n);
|
||||
final content = canvasContentColor(n.color);
|
||||
final border = content ?? tokens.globalBorder;
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(5)), Paint()..color = content?.withValues(alpha: 0.10) ?? tokens.panelBackground);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(5)),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = n.id == selected ? 2 : 1
|
||||
..color = n.id == selected ? tokens.globalFocus : border,
|
||||
);
|
||||
final label = switch (n) {
|
||||
TextNode(:final text) => text,
|
||||
FileNode(:final file) => _basename(file),
|
||||
LinkNode(:final url) => url,
|
||||
_ => '',
|
||||
};
|
||||
if (label.isNotEmpty && rect.width > 16 && rect.height > 12) {
|
||||
_text(canvas, label, Offset(rect.left + 6, rect.top + 5), tokens.globalForeground, maxWidth: rect.width - 12, maxLines: (rect.height ~/ 16).clamp(1, 6));
|
||||
}
|
||||
}
|
||||
|
||||
void _edge(ui.Canvas canvas, CanvasViewport vp, CanvasEdge e, Map<String, CanvasNode> byId) {
|
||||
final from = byId[e.fromNode], to = byId[e.toNode];
|
||||
if (from == null || to == null) return;
|
||||
final a = _anchor(vp.rectOf(from), e.fromSide);
|
||||
final b = _anchor(vp.rectOf(to), e.toSide);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5
|
||||
..color = canvasContentColor(e.color) ?? tokens.dividerColor;
|
||||
canvas.drawLine(a, b, paint);
|
||||
if (e.toEnd == CanvasEnd.arrow) _arrowhead(canvas, b, a, paint.color);
|
||||
if (e.fromEnd == CanvasEnd.arrow) _arrowhead(canvas, a, b, paint.color);
|
||||
}
|
||||
|
||||
/// The attach point on a node rect: the midpoint of [side], or the centre.
|
||||
Offset _anchor(Rect r, CanvasSide? side) => switch (side) {
|
||||
CanvasSide.top => r.topCenter,
|
||||
CanvasSide.bottom => r.bottomCenter,
|
||||
CanvasSide.left => r.centerLeft,
|
||||
CanvasSide.right => r.centerRight,
|
||||
null => r.center,
|
||||
};
|
||||
|
||||
void _arrowhead(ui.Canvas canvas, Offset tip, Offset from, Color color) {
|
||||
final dir = (tip - from);
|
||||
final len = dir.distance;
|
||||
if (len < 0.01) return;
|
||||
final u = dir / len;
|
||||
const size = 7.0;
|
||||
final base = tip - u * size;
|
||||
final perp = Offset(-u.dy, u.dx) * (size * 0.5);
|
||||
final path = Path()
|
||||
..moveTo(tip.dx, tip.dy)
|
||||
..lineTo(base.dx + perp.dx, base.dy + perp.dy)
|
||||
..lineTo(base.dx - perp.dx, base.dy - perp.dy)
|
||||
..close();
|
||||
canvas.drawPath(path, Paint()..color = color);
|
||||
}
|
||||
|
||||
void _text(ui.Canvas canvas, String text, Offset at, Color color, {required double maxWidth, int maxLines = 1, double size = 12}) {
|
||||
if (maxWidth < 4) return;
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(fontSize: size, color: color),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: maxLines,
|
||||
ellipsis: '…',
|
||||
)..layout(maxWidth: maxWidth);
|
||||
tp.paint(canvas, at);
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
final slash = path.lastIndexOf('/');
|
||||
return slash >= 0 ? path.substring(slash + 1) : path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CanvasPainter old) => !identical(old.doc, doc) || old.tokens != tokens || old.zoom != zoom || old.pan != pan || old.selected != selected;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/// The canvas workspace pane body (T-322): renders the extension-owned
|
||||
/// [MultitabController] of open `.canvas` documents as real sub-tabs.
|
||||
/// The controller lives app-scoped on [CanvasExtension] (the diff/T-233
|
||||
/// pattern) so open documents survive the pane being torn down while the
|
||||
/// user works in another workspace tab.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/canvas/src/canvas_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/canvas/json_canvas.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class CanvasPaneHost extends StatelessWidget {
|
||||
const CanvasPaneHost({super.key, this.tabs});
|
||||
|
||||
/// Open documents, keyed by workspace path. Null until the extension
|
||||
/// activates (the contribution builder captures the field lazily).
|
||||
final MultitabController<String>? tabs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = tabs;
|
||||
if (controller == null) return const _EmptyHint();
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
if (controller.isEmpty) return const _EmptyHint();
|
||||
return MultitabPane<String>(
|
||||
controller: controller,
|
||||
// Keep every document's State (parsed doc, pan/zoom, selection)
|
||||
// alive across sub-tab switches.
|
||||
keepAlive: true,
|
||||
bodyBuilder: (_, entry) => CanvasDocumentTab(path: entry.payload),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.canvas', placeholder: 'Open a .canvas file to view it here.'),
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One open `.canvas` document: fetches [path] through `files.read`, parses
|
||||
/// the JSONCanvas model, and hands it to the interactive [CanvasView].
|
||||
class CanvasDocumentTab extends StatefulWidget {
|
||||
const CanvasDocumentTab({super.key, required this.path});
|
||||
|
||||
final String path;
|
||||
|
||||
@override
|
||||
State<CanvasDocumentTab> createState() => _CanvasDocumentTabState();
|
||||
}
|
||||
|
||||
class _CanvasDocumentTabState extends State<CanvasDocumentTab> {
|
||||
CanvasDoc? _doc;
|
||||
String? _error;
|
||||
bool _requested = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requested) return;
|
||||
_requested = true;
|
||||
unawaited(_load(ClideKernel.of(context)));
|
||||
}
|
||||
|
||||
Future<void> _load(KernelServices kernel) async {
|
||||
final resp = await kernel.ipc.request('files.read', args: {'path': widget.path});
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() => _error = resp.error?.message ?? widget.path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final doc = CanvasDoc.parse(resp.data['content'] as String? ?? '');
|
||||
setState(() => _doc = doc);
|
||||
} on FormatException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final error = _error;
|
||||
if (error != null) {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(error, muted: true));
|
||||
}
|
||||
final doc = _doc;
|
||||
if (doc == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.canvas', placeholder: 'Loading…'), muted: true),
|
||||
);
|
||||
}
|
||||
return CanvasView(doc: doc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/// The interactive `.canvas` view (T-322): lays a [CanvasDoc] out with
|
||||
/// [CanvasPainter] and wires pan (drag), zoom (scroll wheel), and click-select
|
||||
/// via [hitTestCanvasNode] so the selection ring lands on what's drawn. Node
|
||||
/// drag/resize and edit affordances layer on later.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
|
||||
import 'package:clide/src/canvas/json_canvas.dart';
|
||||
import 'package:clide/widgets/src/clide_settings.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class CanvasView extends StatefulWidget {
|
||||
const CanvasView({super.key, required this.doc, this.onSelect});
|
||||
|
||||
final CanvasDoc doc;
|
||||
|
||||
/// Fired with the selected node's id (or null when the click misses).
|
||||
final void Function(String? nodeId)? onSelect;
|
||||
|
||||
@override
|
||||
State<CanvasView> createState() => _CanvasViewState();
|
||||
}
|
||||
|
||||
class _CanvasViewState extends State<CanvasView> {
|
||||
static const double _minZoom = 0.2, _maxZoom = 5;
|
||||
|
||||
double _zoom = 1;
|
||||
Offset _pan = Offset.zero;
|
||||
String? _selected;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CanvasView old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!identical(old.doc, widget.doc)) {
|
||||
_zoom = 1;
|
||||
_pan = Offset.zero;
|
||||
_selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onScroll(PointerScrollEvent e) {
|
||||
final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9;
|
||||
final next = (_zoom * factor).clamp(_minZoom, _maxZoom);
|
||||
if (next != _zoom) setState(() => _zoom = next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final size = constraints.biggest;
|
||||
return Listener(
|
||||
onPointerSignal: (s) {
|
||||
if (s is PointerScrollEvent) _onScroll(s);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTapUp: (d) {
|
||||
final hit = hitTestCanvasNode(widget.doc, d.localPosition, size, zoom: _zoom, pan: _pan);
|
||||
if (hit != _selected) setState(() => _selected = hit);
|
||||
widget.onSelect?.call(hit);
|
||||
},
|
||||
onPanUpdate: (d) => setState(() => _pan += d.delta),
|
||||
child: CustomPaint(
|
||||
size: size,
|
||||
painter: CanvasPainter(doc: widget.doc, tokens: tokens, zoom: _zoom, pan: _pan, selected: _selected),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,98 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
import 'package:clide/builtin/canvas/src/canvas_pane_host.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
/// Tier-5 interactive `.canvas` pane (T-322). Renders Obsidian JSONCanvas
|
||||
/// documents in a workspace tab; each open document is a real sub-tab
|
||||
/// (MultitabPane). Opens route in as `selection` messages — from
|
||||
/// `openWorkspaceFile` (file tree, quick-open) and from
|
||||
/// `clide ui open canvas [path]` (D-6 parity, the diff/T-233 pattern).
|
||||
class CanvasExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.canvas';
|
||||
@override
|
||||
String get title => 'Canvas';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
/// App-scoped so open documents survive the pane view being (re)built
|
||||
/// while another workspace tab is active. Built in [activate]; the
|
||||
/// contribution's build closure reads the field at widget-build time.
|
||||
MultitabController<String>? _tabs;
|
||||
StreamSubscription<Message>? _selectionSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
String? _projectRoot;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'canvas.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Canvas',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -60, // below the readers' home surfaces, near diff (-70)
|
||||
build: (_) => CanvasPaneHost(tabs: _tabs),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_tabs = MultitabController<String>();
|
||||
_selectionSub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
|
||||
final path = msg.data['path'];
|
||||
if (path is! String || path.isEmpty) return;
|
||||
openPath(path);
|
||||
ctx.panels.activateTab(Slots.workspace, 'canvas.view');
|
||||
});
|
||||
_projectSub = ctx.events.on<ProjectOpened>().listen(_onProjectChanged);
|
||||
}
|
||||
|
||||
/// Focus the sub-tab for [path], opening one when the document isn't
|
||||
/// open yet. Tab id is the path itself — one tab per document.
|
||||
void openPath(String path) {
|
||||
final tabs = _tabs;
|
||||
if (tabs == null) return;
|
||||
if (tabs.entries.any((e) => e.id == path)) {
|
||||
tabs.activate(path);
|
||||
} else {
|
||||
tabs.add(MultitabEntry<String>(id: path, title: _basename(path), payload: path));
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths of the open documents, oldest-first.
|
||||
List<String> get openPaths => _tabs?.entries.map((e) => e.id).toList() ?? const [];
|
||||
|
||||
/// Drop every open document when the workspace switches in place
|
||||
/// (T-269): the old repo's paths don't resolve in the new one.
|
||||
void _onProjectChanged(ProjectOpened e) {
|
||||
final prev = _projectRoot;
|
||||
_projectRoot = e.path;
|
||||
if (prev == null || prev == e.path) return;
|
||||
final tabs = _tabs;
|
||||
if (tabs == null) return;
|
||||
for (final id in tabs.entries.map((x) => x.id).toList()) {
|
||||
tabs.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
final i = path.lastIndexOf('/');
|
||||
return i < 0 ? path : path.substring(i + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
await _selectionSub?.cancel();
|
||||
_selectionSub = null;
|
||||
await _projectSub?.cancel();
|
||||
_projectSub = null;
|
||||
_tabs?.dispose();
|
||||
_tabs = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -23,6 +23,7 @@ library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/path_preset.dart' show applyPathPreset;
|
||||
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
||||
|
||||
@@ -58,22 +59,57 @@ 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:
|
||||
///
|
||||
/// * `CLIDE_SOCK` — the per-workspace socket ([workspaceSocketPath], D-70).
|
||||
/// * `CLIDE_WORKSPACE` — the workspace root.
|
||||
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
|
||||
/// is not already resolvable), otherwise left untouched.
|
||||
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
|
||||
/// * `PATH` — the workspace's preset dirs (D-106), then [clideCliDir] when it
|
||||
/// is non-null (i.e. `clide` is not already resolvable), then
|
||||
/// [currentPath]. Exported whenever there is anything to prepend —
|
||||
/// the preset must reach the session's Bash tool even when `clide` is
|
||||
/// already on PATH — and left untouched otherwise.
|
||||
Map<String, String> agentEnvDelta({
|
||||
required String workspaceRoot,
|
||||
required String socketPath,
|
||||
required String? currentPath,
|
||||
required String? clideCliDir,
|
||||
List<String> prependDirs = const [],
|
||||
}) {
|
||||
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
|
||||
if (clideCliDir != null && clideCliDir.isNotEmpty) {
|
||||
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
|
||||
final prepend = <String>[...prependDirs, if (clideCliDir != null && clideCliDir.isNotEmpty) clideCliDir];
|
||||
if (prepend.isNotEmpty) {
|
||||
delta['PATH'] = applyPathPreset(currentPath ?? '', prepend);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
/// 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,11 +143,18 @@ class AgentBootstrap {
|
||||
/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs]
|
||||
/// carries the context note; team callers append their own preamble and the
|
||||
/// orchestrator merges both into one `--append-system-prompt`.
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
||||
AgentBootstrap agentBootstrap(
|
||||
String workspaceRoot, {
|
||||
Map<String, String>? base,
|
||||
String? Function(String cwd)? boundConfigDir,
|
||||
List<String> Function(String cwd)? pathPreset,
|
||||
}) {
|
||||
final home = Platform.environment['HOME'];
|
||||
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
|
||||
// shells out to — find user-installed components on a desktop launch, not just
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir and the
|
||||
// workspace's PATH preset (D-106), injected as a plain lookup like
|
||||
// [boundConfigDir] so this stays Flutter-free.
|
||||
final currentPath = resolvedToolPath();
|
||||
final candidates = <String>[
|
||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||
@@ -119,8 +162,19 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
|
||||
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
|
||||
final delta = agentEnvDelta(
|
||||
workspaceRoot: workspaceRoot,
|
||||
socketPath: workspaceSocketPath(workspaceRoot),
|
||||
currentPath: currentPath,
|
||||
clideCliDir: cliDir,
|
||||
prependDirs: pathPreset?.call(workspaceRoot) ?? const [],
|
||||
);
|
||||
// Per-repo Claude account (T-484): a bound workspace runs claude under that
|
||||
// account's CLAUDE_CONFIG_DIR. Spread BEFORE base so an explicit per-call
|
||||
// SpawnSpec.env override still wins (precedence: override > binding > parent
|
||||
// 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) {
|
||||
|
||||
@@ -50,6 +50,7 @@ class ClaudeComposer extends StatefulWidget {
|
||||
this.onInterrupt,
|
||||
this.busy = false,
|
||||
this.onCycleMode,
|
||||
this.onCycleModeFull,
|
||||
this.permissionMode,
|
||||
this.onSetPermissionMode,
|
||||
this.initialValue,
|
||||
@@ -83,11 +84,17 @@ class ClaudeComposer extends StatefulWidget {
|
||||
/// (when the typeahead is closed). The escape hatch for a runaway turn.
|
||||
final VoidCallback? onInterrupt;
|
||||
|
||||
/// Cycle the session's permission mode — fired by Ctrl/Cmd+M while the
|
||||
/// composer is focused (T-226). Intercepted here (not a global keymap
|
||||
/// binding) so it targets this pane's session. Null disables the chord.
|
||||
/// Cycle the session's permission mode through the safe trio — fired by
|
||||
/// Ctrl/Cmd+M while the composer is focused (T-226). Intercepted here
|
||||
/// (not a global keymap binding) so it targets this pane's session.
|
||||
/// Null disables the chord.
|
||||
final VoidCallback? onCycleMode;
|
||||
|
||||
/// Cycle through the FULL mode list including bypassPermissions — fired
|
||||
/// by Ctrl/Cmd+Shift+M (T-510). Holding shift is the explicit opt-in
|
||||
/// for the footgun. Null disables the chord.
|
||||
final VoidCallback? onCycleModeFull;
|
||||
|
||||
/// Whether a turn is in flight; shows the Stop affordance.
|
||||
final bool busy;
|
||||
|
||||
@@ -255,13 +262,22 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
// Ctrl/Cmd+M: cycle the session's permission mode (T-226). Intercepted
|
||||
// here so it targets this pane. (Shift+Tab — the CLI chord — is off the
|
||||
// table: it's a real a11y focus-traversal binding.)
|
||||
// Ctrl/Cmd+M: cycle the session's permission mode through the safe trio
|
||||
// (T-226); with Shift held, through the full list including bypass
|
||||
// (T-510). Intercepted here so it targets this pane. (Shift+Tab — the
|
||||
// CLI chord — is off the table: it's a real a11y focus-traversal
|
||||
// binding.)
|
||||
final mod = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
|
||||
if (mod && e.logicalKey == LogicalKeyboardKey.keyM && widget.onCycleMode != null) {
|
||||
widget.onCycleMode!();
|
||||
return KeyEventResult.handled;
|
||||
if (mod && e.logicalKey == LogicalKeyboardKey.keyM) {
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (shift && widget.onCycleModeFull != null) {
|
||||
widget.onCycleModeFull!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (!shift && widget.onCycleMode != null) {
|
||||
widget.onCycleMode!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
// Escape: dismiss the typeahead if open, otherwise interrupt the running
|
||||
// turn — the escape hatch from a runaway (D-78).
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -612,14 +613,23 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
/// Cycle this pane's session through the safe permission-mode trio
|
||||
/// (default → acceptEdits → plan → default), sent over the stream-json
|
||||
/// control channel (T-226). bypassPermissions is not reachable here — it
|
||||
/// stays behind the explicit confirmed path in the cockpit roster (T-181).
|
||||
/// control channel (T-226). bypassPermissions is not in the plain chord —
|
||||
/// it's in the shift-modified full cycle ([_cycleModeFull], T-510).
|
||||
void _cycleMode() {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
s.setPermissionMode(nextSafePermissionMode(_status.permissionMode ?? 'default'));
|
||||
}
|
||||
|
||||
/// Cycle through the full mode list including bypassPermissions —
|
||||
/// Ctrl/Cmd+Shift+M, where holding shift is the explicit opt-in for the
|
||||
/// footgun (T-510).
|
||||
void _cycleModeFull() {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
s.setPermissionMode(nextPermissionMode(_status.permissionMode ?? 'default'));
|
||||
}
|
||||
|
||||
/// Focus the composer when the user taps empty conversation area (T-227).
|
||||
/// No-op while a prompt occupies the interaction zone (D-78) — a
|
||||
/// background tap must never pull focus from (or resurrect) the composer
|
||||
@@ -834,6 +844,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
busy: busySnap.data ?? false,
|
||||
onInterrupt: _session?.interrupt,
|
||||
onCycleMode: _cycleMode,
|
||||
onCycleModeFull: _cycleModeFull,
|
||||
permissionMode: _status.permissionMode,
|
||||
onSetPermissionMode: _session != null ? (m) => _session!.setPermissionMode(m) : null,
|
||||
onSubmit: _send,
|
||||
@@ -854,7 +865,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).
|
||||
|
||||
@@ -41,10 +41,16 @@ String shortModelLabel(String model) {
|
||||
}
|
||||
|
||||
/// The safe permission-mode cycle: default → acceptEdits → plan → default
|
||||
/// (T-226/T-181). `bypassPermissions` is intentionally excluded — it's
|
||||
/// reachable only via an explicit confirmed path (the footgun guard).
|
||||
/// (T-226/T-181). `bypassPermissions` is intentionally excluded from the
|
||||
/// plain chord — it lives in [kFullPermissionCycle] behind the shift
|
||||
/// modifier (T-510).
|
||||
const List<String> kSafePermissionCycle = ['default', 'acceptEdits', 'plan'];
|
||||
|
||||
/// The full cycle including the bypass footgun. Reachable only through
|
||||
/// shift-modified gestures (Ctrl/Cmd+Shift+M, shift-click on the popup's
|
||||
/// bypass row) — holding shift is the explicit opt-in (T-510).
|
||||
const List<String> kFullPermissionCycle = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
|
||||
|
||||
/// The next mode in [kSafePermissionCycle] after [current] (wraps). An
|
||||
/// unknown or `bypassPermissions` current restarts the cycle at `default`.
|
||||
String nextSafePermissionMode(String current) {
|
||||
@@ -52,6 +58,13 @@ String nextSafePermissionMode(String current) {
|
||||
return kSafePermissionCycle[(i + 1) % kSafePermissionCycle.length];
|
||||
}
|
||||
|
||||
/// The next mode in [kFullPermissionCycle] after [current] (wraps). An
|
||||
/// unknown current restarts the cycle at `default`.
|
||||
String nextPermissionMode(String current) {
|
||||
final i = kFullPermissionCycle.indexOf(current);
|
||||
return kFullPermissionCycle[(i + 1) % kFullPermissionCycle.length];
|
||||
}
|
||||
|
||||
/// Status-line segments split around the permission-mode badge so the UI can
|
||||
/// render the mode as an interactive control between them (T-226). `leading`
|
||||
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
|
||||
|
||||
@@ -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)],
|
||||
],
|
||||
),
|
||||
@@ -1162,10 +1210,11 @@ 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, LS)
|
||||
/// have no catalog key and fall back to the raw name via the placeholder.
|
||||
/// 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);
|
||||
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.
|
||||
@@ -1276,5 +1325,105 @@ String _summarizeActivity(BuildContext context, ConversationItem item) {
|
||||
return text;
|
||||
case ImageMessage(:final 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/src/env/path_preset.dart' show presetDirsFrom;
|
||||
import 'package:clide/builtin/claude/src/account_login_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_settings_control.dart';
|
||||
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 +20,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 +53,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 +208,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 +511,14 @@ 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; the PATH-preset lookup (D-106) prepends the workspace's preset
|
||||
// dirs to every hosted session's PATH — read live at each spawn.
|
||||
_orchestrator = ClaudeSessionOrchestrator(
|
||||
accountRegistry: AccountRegistry(ctx.settings),
|
||||
pathPresetFor: (cwd) => presetDirsFrom((k) => ctx.settings.get<Object>(k), cwd),
|
||||
);
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// An in-place workspace switch (Open Project/Folder) must not leave the
|
||||
@@ -482,9 +532,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 +652,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?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (10–48), 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: [
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
///
|
||||
/// Shows the current mode as a per-mode coloured glyph; clicking opens a menu
|
||||
/// of the safe trio (default / accept-edits / plan) with the active one marked,
|
||||
/// plus a divided, disabled `bypass` row — that footgun stays behind the
|
||||
/// cockpit's explicit confirmed path (T-181), never one click away here. The
|
||||
/// label lives in the tooltip, the menu rows, and the status-bar indicator —
|
||||
/// the resting button is the glyph alone.
|
||||
/// plus a divided `bypass` row gated behind shift-click (T-510): a plain click
|
||||
/// no-ops and keeps the menu open, shift-click selects — the footgun is never
|
||||
/// a *plain* click away, and holding shift is the explicit opt-in (same
|
||||
/// convention as the roster badge, T-181). The label lives in the tooltip, the
|
||||
/// menu rows, and the status-bar indicator — the resting button is the glyph
|
||||
/// alone.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show kSafePermissionCycle, permissionModeLabel;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Per-mode glyph. `bypass` reuses a warning shield; the safe trio gets a
|
||||
@@ -66,7 +69,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
|
||||
List<ClideMenuEntry> _entries(BuildContext ctx, SurfaceTokens tokens) => [
|
||||
for (final m in kSafePermissionCycle)
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon(m),
|
||||
@@ -76,13 +79,33 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
onSelect: () => widget.onSelect(m),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
// The footgun row (T-510): a plain click/Enter no-ops and keeps the
|
||||
// menu open; with shift held it selects and closes. The trailing hint
|
||||
// names the gesture (suppressed while active — the check mark wins).
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon('bypassPermissions'),
|
||||
color: permissionModeColor('bypassPermissions', tokens),
|
||||
label: permissionModeLabel('bypassPermissions'),
|
||||
enabled: false,
|
||||
active: widget.mode == 'bypassPermissions',
|
||||
onSelect: () {},
|
||||
keepOpenOnSelect: true,
|
||||
semanticLabel: ClideSettings.i18n.string(
|
||||
ctx,
|
||||
'permissionControl.bypassSemantics',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: 'bypassPermissions — shift-click to enable',
|
||||
),
|
||||
trailing: widget.mode == 'bypassPermissions'
|
||||
? null
|
||||
: ClideText(
|
||||
ClideSettings.i18n.string(ctx, 'permissionControl.bypassHint', namespace: 'builtin.claude', placeholder: 'shift-click'),
|
||||
fontSize: 10,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
onSelect: () {
|
||||
if (!HardwareKeyboard.instance.isShiftPressed) return;
|
||||
widget.onSelect('bypassPermissions');
|
||||
_overlay.close();
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -94,7 +117,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ctx, ClideSettings.theme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
@@ -115,7 +138,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
context,
|
||||
'permissionControl.tooltip',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles)',
|
||||
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)',
|
||||
replacers: [I18nReplacer(from: '{mode}', replace: permissionModeLabel(widget.mode))],
|
||||
),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
|
||||
@@ -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,20 @@ class ManagedSession {
|
||||
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry, this.pathPresetFor}) : _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;
|
||||
|
||||
/// Per-workspace PATH preset lookup (D-106): dirs prepended to a hosted
|
||||
/// session's PATH at spawn, wired by the extension over the settings store.
|
||||
/// Null in tests / when not wired → no injection.
|
||||
final List<String> Function(String cwd)? pathPresetFor;
|
||||
|
||||
final ProcessFactory _factory;
|
||||
final _sessions = <String, ManagedSession>{};
|
||||
|
||||
@@ -238,13 +249,23 @@ 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,
|
||||
pathPreset: pathPresetFor,
|
||||
);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
@@ -312,6 +333,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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:clide/builtin/graph/src/graph_panel.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class GraphExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -11,5 +14,16 @@ class GraphExtension extends ClideExtension {
|
||||
List<String> get dependsOn => const ['builtin.pql'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'graph.view',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Graph',
|
||||
titleKey: 'tab.graph.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.byName('graph'),
|
||||
priority: -70,
|
||||
build: (_) => const GraphPanel(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/// Loads the whole vault's link graph from pql (T-323): lists every markdown
|
||||
/// file (nodes), then fetches each file's `pql.meta` for its outlinks (edges)
|
||||
/// and tags, assembling a [VaultGraph] plus a per-file tag map. Holds the file
|
||||
/// glob + a client-side [GraphFilter] (tag include/exclude, depth-from-active)
|
||||
/// and exposes the filtered [visibleGraph] the pane draws.
|
||||
///
|
||||
/// One `pql.meta` per file gives both outlinks and tags in a single call. Link
|
||||
/// targets carry `#heading` fragments (`foo.md#bar`); those are stripped to the
|
||||
/// file (`foo.md`) so a heading link still connects the two notes.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/graph/graph_filter.dart';
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class GraphController extends ChangeNotifier {
|
||||
GraphController({required this.ipc, required this.events, String glob = '**/*.md', this.refreshDebounce = const Duration(milliseconds: 400)}) : _glob = glob {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final DaemonBus events;
|
||||
|
||||
/// A save touches several `files.changed` events in a burst — coalesce them
|
||||
/// into one full reload rather than rebuilding the graph per file.
|
||||
final Duration refreshDebounce;
|
||||
|
||||
String _glob;
|
||||
String get glob => _glob;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
Timer? _debounce;
|
||||
|
||||
VaultGraph _graph = const VaultGraph([], []);
|
||||
VaultGraph get graph => _graph;
|
||||
|
||||
Map<String, Set<String>> _tagsByPath = const {};
|
||||
|
||||
/// Every tag present in the loaded vault, sorted — what the filter UI offers.
|
||||
List<String> get availableTags {
|
||||
final all = <String>{for (final s in _tagsByPath.values) ...s};
|
||||
return all.toList()..sort();
|
||||
}
|
||||
|
||||
String? _activePath;
|
||||
String? get activePath => _activePath;
|
||||
|
||||
GraphFilter _filter = const GraphFilter();
|
||||
GraphFilter get filter => _filter;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
/// The graph after the active [filter] — what the pane draws.
|
||||
VaultGraph get visibleGraph => _filter.apply(_graph, tagsByPath: _tagsByPath, activePath: _activePath);
|
||||
|
||||
/// List every in-scope file, fetch each one's meta (outlinks + tags), and
|
||||
/// rebuild the graph. A failed `pql.files` clears everything and surfaces the
|
||||
/// error; a failed per-file `pql.meta` just contributes no edges/tags for it.
|
||||
Future<void> load() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final filesResp = await ipc.request('pql.files', args: {'glob': _glob});
|
||||
if (!filesResp.ok) {
|
||||
_graph = const VaultGraph([], []);
|
||||
_tagsByPath = const {};
|
||||
_error = filesResp.error?.message ?? 'pql.files failed';
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final paths = [
|
||||
for (final f in _castList(filesResp.data['files']))
|
||||
if (f['path'] is String) f['path'] as String,
|
||||
];
|
||||
|
||||
final outlinks = <String, List<String>>{};
|
||||
final tags = <String, Set<String>>{};
|
||||
for (final path in paths) {
|
||||
final resp = await ipc.request('pql.meta', args: {'path': path});
|
||||
if (!resp.ok) {
|
||||
outlinks[path] = const [];
|
||||
continue;
|
||||
}
|
||||
outlinks[path] = [
|
||||
for (final l in _castList(resp.data['outlinks']))
|
||||
if (l['target'] is String) _stripFragment(l['target'] as String),
|
||||
].where((t) => t.isNotEmpty).toList();
|
||||
final t = resp.data['tags'];
|
||||
if (t is List) {
|
||||
final set = {
|
||||
for (final e in t)
|
||||
if (e is String) e,
|
||||
};
|
||||
if (set.isNotEmpty) tags[path] = set;
|
||||
}
|
||||
}
|
||||
|
||||
_graph = VaultGraph.fromOutlinks(outlinks);
|
||||
_tagsByPath = tags;
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Change the file set the graph spans. An empty glob resets to all markdown.
|
||||
/// A real change re-queries pql; the same glob is a no-op.
|
||||
void setGlob(String glob) {
|
||||
final g = glob.trim().isEmpty ? '**/*.md' : glob.trim();
|
||||
if (g == _glob) return;
|
||||
_glob = g;
|
||||
unawaited(load());
|
||||
}
|
||||
|
||||
/// Replace the client-side filter. No reload — [visibleGraph] recomputes.
|
||||
void setFilter(GraphFilter filter) {
|
||||
_filter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem == 'editor' && e.kind == 'editor.active-changed') {
|
||||
final p = e.data['path'] as String?;
|
||||
if (p != _activePath) {
|
||||
_activePath = p;
|
||||
notifyListeners(); // a depth filter re-centres on the new active note
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.subsystem != 'files') return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(refreshDebounce, () => unawaited(load()));
|
||||
}
|
||||
|
||||
static String _stripFragment(String target) {
|
||||
final hash = target.indexOf('#');
|
||||
return hash < 0 ? target : target.substring(0, hash);
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||