Compare commits
+13
-10
@@ -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.
|
||||
|
||||
@@ -113,29 +120,32 @@ Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
|
||||
The model-identifier variant produced by the Claude Code harness (e.g. `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`) is also accepted — don't rewrite it if the harness emits that form.
|
||||
|
||||
## HEREDOC discipline
|
||||
## Message-file discipline
|
||||
|
||||
Pass commit messages via HEREDOC so multi-line formatting survives:
|
||||
Pass commit messages via a message file so multi-line formatting survives
|
||||
and the command stays inside the permission allowlist (command substitution
|
||||
defeats the `git commit` prefix match and triggers permission prompts):
|
||||
|
||||
1. Write the full message to a file in `/tmp` (never inside `.git/` — that
|
||||
directory is git's own state) using the Write tool.
|
||||
2. Commit with it:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
short imperative summary
|
||||
|
||||
Optional longer body explaining why this change was needed,
|
||||
wrapped at about 72 characters.
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git commit -F /tmp/commit-msg.txt
|
||||
```
|
||||
|
||||
Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs between shells and quoting regimes and the trailer can end up in the wrong place.
|
||||
Fallback only: the HEREDOC-in-substitution form
|
||||
(`git commit -m "$(cat <<'EOF' … EOF)"`) works but prompts for permission —
|
||||
use it only when writing a file is impossible. Never pass multi-line messages
|
||||
via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs
|
||||
between shells and quoting regimes and the trailer can end up in the wrong
|
||||
place.
|
||||
|
||||
## What not to commit
|
||||
|
||||
- `.env` and any `*.env.local` — see `.gitignore`.
|
||||
- `.claude/settings.local.json` — user-specific Claude Code settings, ignored.
|
||||
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/` (Flutter output), `app/.dart_tool/` — gitignored.
|
||||
- Build artefacts: `/build/` (Flutter output), `/.dart_tool/` — gitignored.
|
||||
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
|
||||
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Regular → Executable
+17
-23
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -22,6 +22,43 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
## [2.10.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Canvas pane.** `.canvas` (Obsidian JSONCanvas) files now open in an
|
||||
interactive workspace pane — each document a sub-tab, with pan, zoom, and
|
||||
node selection. Routed from the file tree, quick-open, and
|
||||
`clide ui open canvas <path>`. (T-322)
|
||||
- **Vault graph view.** A force-directed link graph of the whole vault in the
|
||||
context panel — notes are nodes, wikilinks edges. Hover highlights a note's
|
||||
neighbourhood; click opens it. Scroll to zoom, drag to pan; filter by path
|
||||
glob, tag include/exclude, or depth from the active note. (T-323)
|
||||
- **Per-workspace PATH preset.** Directories prepended to the PATH of every
|
||||
shell clide spawns for a repo — Claude sessions and terminal panes. Settings
|
||||
→ Tools → Workspace PATH, or `clide env path …` with capture-from-login-shell
|
||||
suggestions. Worktrees share their repo's preset; stored machine-local, never
|
||||
committed. (D-106, T-511)
|
||||
|
||||
### Changed
|
||||
|
||||
- Permission mode: Ctrl/Cmd+Shift+M cycles the full list including
|
||||
bypassPermissions, and the composer menu's bypass row unlocks via
|
||||
shift-click — plain Ctrl/Cmd+M and plain clicks stay safe-trio only (T-510).
|
||||
- Bundled Claude skills pruned to clide-specific ones (T-507): broken/duplicate
|
||||
skills removed, repo-agnostic ones moved to user scope, and stale guidance in
|
||||
the remaining five (paths, commands, icon API) brought back in line with the
|
||||
repo.
|
||||
- The `clide` skill description now leads with the hosting mechanics (`clide`
|
||||
on PATH, `CLIDE_SOCK`) and `clide capabilities` as the entry action, so an
|
||||
agent deciding whether to invoke it sees the first move up front (T-512).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hash-keyed settings (per-workspace PATH presets, Claude account bindings)
|
||||
with a number-shaped hash suffix were silently lost on restart — YAML map
|
||||
keys are now quoted on write.
|
||||
|
||||
## [2.9.0] — 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Canvas" },
|
||||
"empty": { "translation": "Open a .canvas file to view it here." },
|
||||
"status.loading": { "translation": "Loading…" }
|
||||
}
|
||||
@@ -106,7 +106,9 @@
|
||||
"tool.name.TodoWrite": { "translation": "TodoWrite" },
|
||||
"tool.name.ExitPlanMode": { "translation": "ExitPlanMode" },
|
||||
"permissionControl.semantics": { "translation": "permission mode: {mode}. Activate to change." },
|
||||
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles)" },
|
||||
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)" },
|
||||
"permissionControl.bypassHint": { "translation": "shift-click" },
|
||||
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-click to enable" },
|
||||
"permissionBadge.tooltip": { "translation": "Permission mode: {mode}. Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions." },
|
||||
"permissionBadge.semantics": { "translation": "Permission mode: {label}" },
|
||||
"card.expand": { "translation": "Expand" },
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -7,5 +7,20 @@
|
||||
"settings.field.d2.label": { "translation": "d2" },
|
||||
"settings.field.d2.help": { "translation": "Absolute path to the d2 diagram compiler; blank to auto-resolve." },
|
||||
"settings.field.detect.label": { "translation": "Re-detect" },
|
||||
"settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." }
|
||||
"settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." },
|
||||
"settings.section.path": { "translation": "Workspace PATH" },
|
||||
"settings.field.pathPreset.label": { "translation": "Prepend to PATH" },
|
||||
"settings.field.pathPreset.help": { "translation": "Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo's preset. Applies to new shells." },
|
||||
"path.noWorkspace": { "translation": "Open a workspace to set its PATH preset." },
|
||||
"path.sharedRoot": { "translation": "Worktree — preset shared with" },
|
||||
"path.empty": { "translation": "No preset entries — spawned shells get the resolved login-shell PATH as-is." },
|
||||
"path.add": { "translation": "Add entry" },
|
||||
"path.invalid": { "translation": "Enter an absolute path (or ~/…)." },
|
||||
"path.missing": { "translation": "missing" },
|
||||
"path.moveUp": { "translation": "Move up" },
|
||||
"path.moveDown": { "translation": "Move down" },
|
||||
"path.remove": { "translation": "Remove" },
|
||||
"path.capture": { "translation": "Suggest from login shell" },
|
||||
"path.captureNone": { "translation": "Nothing to suggest — the login-shell PATH is already covered." },
|
||||
"path.addSuggestion": { "translation": "Add suggested entry" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Canvas" },
|
||||
"empty": { "translation": "Open een .canvas-bestand om het hier te bekijken." },
|
||||
"status.loading": { "translation": "Laden…" }
|
||||
}
|
||||
@@ -106,7 +106,9 @@
|
||||
"tool.name.TodoWrite": { "translation": "Takenlijst" },
|
||||
"tool.name.ExitPlanMode": { "translation": "Planmodus verlaten" },
|
||||
"permissionControl.semantics": { "translation": "permissiemodus: {mode}. Activeer om te wijzigen." },
|
||||
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt)" },
|
||||
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt; +Shift ook bypass)" },
|
||||
"permissionControl.bypassHint": { "translation": "shift-klik" },
|
||||
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-klik om in te schakelen" },
|
||||
"permissionBadge.tooltip": { "translation": "Permissiemodus: {mode}. Klik om te wisselen tussen default/acceptEdits/plan; Shift-klik voor bypassPermissions." },
|
||||
"permissionBadge.semantics": { "translation": "Permissiemodus: {label}" },
|
||||
"card.expand": { "translation": "Uitvouwen" },
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -7,5 +7,20 @@
|
||||
"settings.field.d2.label": { "translation": "d2" },
|
||||
"settings.field.d2.help": { "translation": "Absoluut pad naar de d2-diagramcompiler; leeg om automatisch te bepalen." },
|
||||
"settings.field.detect.label": { "translation": "Opnieuw detecteren" },
|
||||
"settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." }
|
||||
"settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." },
|
||||
"settings.section.path": { "translation": "Workspace-PATH" },
|
||||
"settings.field.pathPreset.label": { "translation": "Vooraan aan PATH toevoegen" },
|
||||
"settings.field.pathPreset.help": { "translation": "Mappen die vóór PATH komen in Claude-sessies en terminalvensters voor deze repo; worktrees delen de preset van de repo. Geldt voor nieuwe shells." },
|
||||
"path.noWorkspace": { "translation": "Open een workspace om de PATH-preset in te stellen." },
|
||||
"path.sharedRoot": { "translation": "Worktree — preset gedeeld met" },
|
||||
"path.empty": { "translation": "Geen preset-items — nieuwe shells krijgen het opgeloste login-shell-PATH ongewijzigd." },
|
||||
"path.add": { "translation": "Item toevoegen" },
|
||||
"path.invalid": { "translation": "Voer een absoluut pad in (of ~/…)." },
|
||||
"path.missing": { "translation": "ontbreekt" },
|
||||
"path.moveUp": { "translation": "Omhoog" },
|
||||
"path.moveDown": { "translation": "Omlaag" },
|
||||
"path.remove": { "translation": "Verwijderen" },
|
||||
"path.capture": { "translation": "Voorstellen uit login-shell" },
|
||||
"path.captureNone": { "translation": "Niets voor te stellen — het login-shell-PATH is al gedekt." },
|
||||
"path.addSuggestion": { "translation": "Voorgesteld item toevoegen" }
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ self:
|
||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||
# pubspec instead.
|
||||
version: "2.9.0"
|
||||
version: "2.10.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
|
||||
@@ -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.
|
||||
@@ -147,6 +147,7 @@ You might also want, project-permitting:
|
||||
- [D-103: Drawing-card primitive layer is SVG; the SVG renderer is the engine](decisions/architecture.md#d-103-drawing-card-primitive-layer-is-svg-the-svg-renderer-is-the-engine) — _architecture_
|
||||
- [D-104: Explicit supporter-binary path overrides in user-scope settings](decisions/tooling.md#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings) — _tooling_
|
||||
- [D-105: Support Vibe CLI as opt-in alternative to Claude Code CLI](decisions/llm.md#d-105-support-vibe-cli-as-opt-in-alternative-to-claude-code-cli) — _llm_
|
||||
- [D-106: Per-workspace PATH preset prepended at spawn](decisions/tooling.md#d-106-per-workspace-path-preset-prepended-at-spawn) — _tooling_
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -113,8 +113,16 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
|
||||
### D-104: Explicit supporter-binary path overrides in user-scope settings
|
||||
- **Date:** 2026-06-28
|
||||
- **Decision:** clide resolves each **external** supporter binary (`claude`, `d2`, and future supporter tools — **not** bundled `pql`/`git`, which keep [D-58]/[D-59]) through an **explicit `tools:` map in user-scope settings**: tool-name → absolute path. The map holds concrete, user-visible, user-editable paths and is the **first** step in resolution — if an entry is set, clide uses that exact path (honest error if it is missing / not executable, falling **down the chain with a warning** rather than hard-failing). On **first run**, clide auto-detects each tool **once** — probing the login-shell PATH (T-439) plus well-known dirs (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew`, Homebrew-on-Linux `/home/linuxbrew/.linuxbrew/bin`) — and **writes the discovered absolute paths into the map**, so detection is a one-time *populate*, not a per-launch heuristic; thereafter the pinned explicit value wins. A **re-detect** action re-runs the probe (e.g. after installing a tool). Unset/undetected tools fall back to the existing chain (bundled/pinned per [D-58]/[D-59], then login-shell PATH). **User-scope only, keyed by machine ([D-93]) — never committed** (absolute paths are machine-specific). Generalizes [D-58]'s `CLIDE_PQL_BIN` override to every supporter tool; surfaced in the Config tab and the Problems panel for unresolved tools.
|
||||
- **Decision:** clide resolves each **external** supporter binary (`claude`, `d2`, and future supporter tools — **not** bundled `pql`/`git`, which keep [D-92]/[D-59]) through an **explicit `tools:` map in user-scope settings**: tool-name → absolute path. The map holds concrete, user-visible, user-editable paths and is the **first** step in resolution — if an entry is set, clide uses that exact path (honest error if it is missing / not executable, falling **down the chain with a warning** rather than hard-failing). On **first run**, clide auto-detects each tool **once** — probing the login-shell PATH (T-439) plus well-known dirs (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew`, Homebrew-on-Linux `/home/linuxbrew/.linuxbrew/bin`) — and **writes the discovered absolute paths into the map**, so detection is a one-time *populate*, not a per-launch heuristic; thereafter the pinned explicit value wins. A **re-detect** action re-runs the probe (e.g. after installing a tool). Unset/undetected tools fall back to the existing chain (bundled/pinned per [D-92]/[D-59], then login-shell PATH). **User-scope only, keyed by machine ([D-93]) — never committed** (absolute paths are machine-specific). Generalizes [D-92]'s `CLIDE_PQL_BIN` override to every supporter tool; surfaced in the Config tab and the Problems panel for unresolved tools.
|
||||
- **Rationale:** PATH-probing (T-439) is adaptive but brittle on non-standard installs — a login-shell probe misses Homebrew when `brew shellenv` lives only in `~/.bashrc` (login shells source `.bash_profile`/`.profile`, not `.bashrc`). Materializing resolution into explicit, pinned paths makes it deterministic and debuggable: "tool not found" becomes a one-line settings fix, and the value is *visible* rather than recomputed by heuristic each launch. First-run auto-detect keeps it zero-config for standard installs; **pinning the result instead of re-probing is what makes it explicit** — escaping the heuristic fragility while keeping the convenience.
|
||||
- **Cost:** A first-run detection pass plus a small settings surface. Stale pins (a tool moved on a brew upgrade) must fall back + warn, not hard-fail. The map is per-machine, so it does not travel with the repo — each machine detects once.
|
||||
- **Cross-reference:** [D-58](#d-58-ship-pql-bundled-with-clide) (pql resolution + `CLIDE_PQL_BIN` override), [D-59](#d-59-bundled-git-via-dugite-native), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) (user-scope state), T-439 (login-shell-derived PATH), T-494 (the d2 template — first consumer).
|
||||
- **Cross-reference:** [D-92](#d-92-ship-pql-bundled-with-clide) (pql resolution + `CLIDE_PQL_BIN` override), [D-59](#d-59-bundled-git-via-dugite-native), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) (user-scope state), T-439 (login-shell-derived PATH), T-494 (the d2 template — first consumer).
|
||||
- **Raised by:** 2026-06-28 — user, during the drawing-card SVG/d2 work after the login-shell PATH probe was seen to miss linuxbrew: "should we just add the hard paths to the supporting binaries to the clide settings file?" + "auto detect on first run sounds solid" — explicit pinned paths, populated by a one-time first-run probe.
|
||||
|
||||
### D-106: Per-workspace PATH preset prepended at spawn
|
||||
- **Date:** 2026-07-13
|
||||
- **Decision:** Each workspace can carry a **preset list of directories prepended to the PATH** clide hands to everything it spawns a shell for: the **hosted Claude session** (so the agent's Bash tool sees it — the [D-83](architecture.md#d-83-dogfood-agent-model--hosted-stream-json-session-primary-external-cli-driver-secondary) bootstrap seam that already injects `CLIDE_SOCK`/`CLIDE_WORKSPACE`) and **terminal PTY panes**. Prepend semantics: preset dirs win over the resolved login-shell PATH (T-439), applied at spawn time — no profile sourcing, no login-shell requirement, and only NEW spawns see an edit. Storage is **user-scope, keyed by workspace hash ([D-93]) — never committed** (`app.env.pathPrepend.<hash>`; absolute paths are machine-specific, and a committed preset would let a cloned repo inject PATH). The hash keys off the **repo identity, not the literal directory**: a workspace that is a linked git worktree (its `.git` is a `gitdir:` pointer file, e.g. under an in-repo `.worktrees/` dir — supported, and gitignored by the scaffold) resolves to the **main repo root**, so every worktree shares its repo's preset. **Security fence:** the preset feeds only spawned-shell environments — clide's own binary resolution (pinned `tools:` per [D-104](#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings), bundled pql/git per [D-92](#d-92-ship-pql-bundled-with-clide)/[D-59](#d-59-bundled-git-via-dugite-native), the toolchain probe) never consults it (the T-98 dugite lesson). Parity ([D-6](architecture.md#d-6-cli-and-event-surface-contract)): `clide env path list|set|add|remove|clear|capture` mirrors the settings-panel editor; `capture` suggests entries by diffing the login-shell PATH against the process PATH — "set once by either party".
|
||||
- **Rationale:** The T-439 login-shell probe is a best-effort global heuristic with a known residual hole — a login-but-non-interactive probe misses interactive-only profile additions (brew `shellenv` in `~/.bashrc`), which is exactly the observed failure (Go toolchain invisible to the agent in the pql repo, 2026-07-08). [D-104] made single binaries explicit; this extends the same explicit-over-heuristic philosophy to whole directories, per repo, visible and editable by both the user and the agent instead of re-discovered per session.
|
||||
- **Cost:** Per-machine — a preset does not travel with the repo and re-keys (resets) when a repo moves ([D-93] trade-off, same as account bindings). One more env layer every spawn site must route through the single `shell_env` resolver; already-running shells keep their env until respawned.
|
||||
- **Cross-reference:** [D-104](#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-83](architecture.md#d-83-dogfood-agent-model--hosted-stream-json-session-primary-external-cli-driver-secondary), [D-6](architecture.md#d-6-cli-and-event-surface-contract), [D-92](#d-92-ship-pql-bundled-with-clide), [D-59](#d-59-bundled-git-via-dugite-native), T-439 (login-shell PATH resolver), T-484 (spawn-time `CLAUDE_CONFIG_DIR` injection — the per-workspace spawn-env precedent).
|
||||
- **Raised by:** 2026-07-08 — FR-1, logged from a pql session where the agent shell could not find the Go toolchain: "a settings-panel option (per repo/workspace): a list of PATH entries to prepend, set once, then injected everywhere clide spawns a shell"; worktree keying added 2026-07-13 — user: "make sure the PATH settings take usage of worktrees inside a .worktrees dir inside the repo into account".
|
||||
|
||||
@@ -1 +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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/path_preset.dart' show applyPathPreset;
|
||||
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
||||
|
||||
@@ -74,12 +75,22 @@ String clideSkillsNote() =>
|
||||
///
|
||||
/// * `CLIDE_SOCK` — the per-workspace socket ([workspaceSocketPath], D-70).
|
||||
/// * `CLIDE_WORKSPACE` — the workspace root.
|
||||
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
|
||||
/// is not already resolvable), otherwise left untouched.
|
||||
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
|
||||
/// * `PATH` — the workspace's preset dirs (D-106), then [clideCliDir] when it
|
||||
/// is non-null (i.e. `clide` is not already resolvable), then
|
||||
/// [currentPath]. Exported whenever there is anything to prepend —
|
||||
/// the preset must reach the session's Bash tool even when `clide` is
|
||||
/// already on PATH — and left untouched otherwise.
|
||||
Map<String, String> agentEnvDelta({
|
||||
required String workspaceRoot,
|
||||
required String socketPath,
|
||||
required String? currentPath,
|
||||
required String? clideCliDir,
|
||||
List<String> prependDirs = const [],
|
||||
}) {
|
||||
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
|
||||
if (clideCliDir != null && clideCliDir.isNotEmpty) {
|
||||
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
|
||||
final prepend = <String>[...prependDirs, if (clideCliDir != null && clideCliDir.isNotEmpty) clideCliDir];
|
||||
if (prepend.isNotEmpty) {
|
||||
delta['PATH'] = applyPathPreset(currentPath ?? '', prepend);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
@@ -132,11 +143,18 @@ class AgentBootstrap {
|
||||
/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs]
|
||||
/// carries the context note; team callers append their own preamble and the
|
||||
/// orchestrator merges both into one `--append-system-prompt`.
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base, String? Function(String cwd)? boundConfigDir}) {
|
||||
AgentBootstrap agentBootstrap(
|
||||
String workspaceRoot, {
|
||||
Map<String, String>? base,
|
||||
String? Function(String cwd)? boundConfigDir,
|
||||
List<String> Function(String cwd)? pathPreset,
|
||||
}) {
|
||||
final home = Platform.environment['HOME'];
|
||||
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
|
||||
// shells out to — find user-installed components on a desktop launch, not just
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir and the
|
||||
// workspace's PATH preset (D-106), injected as a plain lookup like
|
||||
// [boundConfigDir] so this stays Flutter-free.
|
||||
final currentPath = resolvedToolPath();
|
||||
final candidates = <String>[
|
||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||
@@ -144,7 +162,13 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base,
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
|
||||
final delta = agentEnvDelta(
|
||||
workspaceRoot: workspaceRoot,
|
||||
socketPath: workspaceSocketPath(workspaceRoot),
|
||||
currentPath: currentPath,
|
||||
clideCliDir: cliDir,
|
||||
prependDirs: pathPreset?.call(workspaceRoot) ?? const [],
|
||||
);
|
||||
// Per-repo Claude account (T-484): a bound workspace runs claude under that
|
||||
// account's CLAUDE_CONFIG_DIR. Spread BEFORE base so an explicit per-call
|
||||
// SpawnSpec.env override still wins (precedence: override > binding > parent
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -613,14 +613,23 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
/// Cycle this pane's session through the safe permission-mode trio
|
||||
/// (default → acceptEdits → plan → default), sent over the stream-json
|
||||
/// control channel (T-226). bypassPermissions is not reachable here — it
|
||||
/// stays behind the explicit confirmed path in the cockpit roster (T-181).
|
||||
/// control channel (T-226). bypassPermissions is not in the plain chord —
|
||||
/// it's in the shift-modified full cycle ([_cycleModeFull], T-510).
|
||||
void _cycleMode() {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
s.setPermissionMode(nextSafePermissionMode(_status.permissionMode ?? 'default'));
|
||||
}
|
||||
|
||||
/// Cycle through the full mode list including bypassPermissions —
|
||||
/// Ctrl/Cmd+Shift+M, where holding shift is the explicit opt-in for the
|
||||
/// footgun (T-510).
|
||||
void _cycleModeFull() {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
s.setPermissionMode(nextPermissionMode(_status.permissionMode ?? 'default'));
|
||||
}
|
||||
|
||||
/// Focus the composer when the user taps empty conversation area (T-227).
|
||||
/// No-op while a prompt occupies the interaction zone (D-78) — a
|
||||
/// background tap must never pull focus from (or resurrect) the composer
|
||||
@@ -835,6 +844,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
busy: busySnap.data ?? false,
|
||||
onInterrupt: _session?.interrupt,
|
||||
onCycleMode: _cycleMode,
|
||||
onCycleModeFull: _cycleModeFull,
|
||||
permissionMode: _status.permissionMode,
|
||||
onSetPermissionMode: _session != null ? (m) => _session!.setPermissionMode(m) : null,
|
||||
onSubmit: _send,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/src/env/path_preset.dart' show presetDirsFrom;
|
||||
import 'package:clide/builtin/claude/src/account_login_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_settings_control.dart';
|
||||
@@ -511,8 +512,13 @@ class ClaudeExtension extends ClideExtension {
|
||||
|
||||
// The clide-managed session set (T-169). Panes spawn/bind through it so a
|
||||
// session outlives its pane and is shared across surfaces. The account
|
||||
// registry (T-476) lets a bound workspace spawn under its own Claude account.
|
||||
_orchestrator = ClaudeSessionOrchestrator(accountRegistry: AccountRegistry(ctx.settings));
|
||||
// registry (T-476) lets a bound workspace spawn under its own Claude
|
||||
// account; the PATH-preset lookup (D-106) prepends the workspace's preset
|
||||
// dirs to every hosted session's PATH — read live at each spawn.
|
||||
_orchestrator = ClaudeSessionOrchestrator(
|
||||
accountRegistry: AccountRegistry(ctx.settings),
|
||||
pathPresetFor: (cwd) => presetDirsFrom((k) => ctx.settings.get<Object>(k), cwd),
|
||||
);
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// An in-place workspace switch (Open Project/Folder) must not leave the
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
///
|
||||
/// Shows the current mode as a per-mode coloured glyph; clicking opens a menu
|
||||
/// of the safe trio (default / accept-edits / plan) with the active one marked,
|
||||
/// plus a divided, disabled `bypass` row — that footgun stays behind the
|
||||
/// cockpit's explicit confirmed path (T-181), never one click away here. The
|
||||
/// label lives in the tooltip, the menu rows, and the status-bar indicator —
|
||||
/// the resting button is the glyph alone.
|
||||
/// plus a divided `bypass` row gated behind shift-click (T-510): a plain click
|
||||
/// no-ops and keeps the menu open, shift-click selects — the footgun is never
|
||||
/// a *plain* click away, and holding shift is the explicit opt-in (same
|
||||
/// convention as the roster badge, T-181). The label lives in the tooltip, the
|
||||
/// menu rows, and the status-bar indicator — the resting button is the glyph
|
||||
/// alone.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show kSafePermissionCycle, permissionModeLabel;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Per-mode glyph. `bypass` reuses a warning shield; the safe trio gets a
|
||||
@@ -66,7 +69,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
|
||||
List<ClideMenuEntry> _entries(BuildContext ctx, SurfaceTokens tokens) => [
|
||||
for (final m in kSafePermissionCycle)
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon(m),
|
||||
@@ -76,13 +79,33 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
onSelect: () => widget.onSelect(m),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
// The footgun row (T-510): a plain click/Enter no-ops and keeps the
|
||||
// menu open; with shift held it selects and closes. The trailing hint
|
||||
// names the gesture (suppressed while active — the check mark wins).
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon('bypassPermissions'),
|
||||
color: permissionModeColor('bypassPermissions', tokens),
|
||||
label: permissionModeLabel('bypassPermissions'),
|
||||
enabled: false,
|
||||
active: widget.mode == 'bypassPermissions',
|
||||
onSelect: () {},
|
||||
keepOpenOnSelect: true,
|
||||
semanticLabel: ClideSettings.i18n.string(
|
||||
ctx,
|
||||
'permissionControl.bypassSemantics',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: 'bypassPermissions — shift-click to enable',
|
||||
),
|
||||
trailing: widget.mode == 'bypassPermissions'
|
||||
? null
|
||||
: ClideText(
|
||||
ClideSettings.i18n.string(ctx, 'permissionControl.bypassHint', namespace: 'builtin.claude', placeholder: 'shift-click'),
|
||||
fontSize: 10,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
onSelect: () {
|
||||
if (!HardwareKeyboard.instance.isShiftPressed) return;
|
||||
widget.onSelect('bypassPermissions');
|
||||
_overlay.close();
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -94,7 +117,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ctx, ClideSettings.theme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
@@ -115,7 +138,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
context,
|
||||
'permissionControl.tooltip',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles)',
|
||||
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)',
|
||||
replacers: [I18nReplacer(from: '{mode}', replace: permissionModeLabel(widget.mode))],
|
||||
),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
|
||||
@@ -153,7 +153,7 @@ class ManagedSession {
|
||||
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry}) : _factory = processFactory ?? _spawnClaude {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry, this.pathPresetFor}) : _factory = processFactory ?? _spawnClaude {
|
||||
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,11 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
/// Null in tests / when no registry is wired → no injection.
|
||||
final AccountRegistry? accountRegistry;
|
||||
|
||||
/// Per-workspace PATH preset lookup (D-106): dirs prepended to a hosted
|
||||
/// session's PATH at spawn, wired by the extension over the settings store.
|
||||
/// Null in tests / when not wired → no injection.
|
||||
final List<String> Function(String cwd)? pathPresetFor;
|
||||
|
||||
final ProcessFactory _factory;
|
||||
final _sessions = <String, ManagedSession>{};
|
||||
|
||||
@@ -255,7 +260,12 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env, boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir);
|
||||
final bootstrap = agentBootstrap(
|
||||
spec.cwd,
|
||||
base: spec.env,
|
||||
boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir,
|
||||
pathPreset: pathPresetFor,
|
||||
);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/// Paints a laid-out [VaultGraph] (T-323): edges as lines, nodes as labelled
|
||||
/// dots, with a hover neighbourhood lit and everything else dimmed.
|
||||
///
|
||||
/// The [ForceLayout] solver produces [positions] in a [layoutSize] space; the
|
||||
/// painter fits that into the canvas (aspect-preserving, centered). The graph is
|
||||
/// clide's own UI here (not arbitrary content), so it paints through
|
||||
/// [SurfaceTokens] — theme chrome, per D-7.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/graph/force_layout.dart';
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The aspect-preserving, centered fit of the solver's [layoutSize] space into a
|
||||
/// canvas — shared by the painter and hit-testing so hover/click land exactly on
|
||||
/// what's drawn.
|
||||
class GraphViewport {
|
||||
GraphViewport(this.scale, this.dx, this.dy);
|
||||
final double scale, dx, dy;
|
||||
|
||||
/// Fits the solver's [layout] space into [canvas] (aspect-preserving), then
|
||||
/// applies the user's [zoom] (about the canvas centre) and [pan]. At
|
||||
/// `zoom: 1, pan: zero` this is the plain centered fit.
|
||||
factory GraphViewport.fit(Size canvas, Size layout, {double zoom = 1, Offset pan = Offset.zero}) {
|
||||
final scale = math.min(canvas.width / layout.width, canvas.height / layout.height) * zoom;
|
||||
// Pin the layout centre to the canvas centre so zoom scales about it, then
|
||||
// translate by the pan.
|
||||
final dx = canvas.width / 2 + pan.dx - scale * layout.width / 2;
|
||||
final dy = canvas.height / 2 + pan.dy - scale * layout.height / 2;
|
||||
return GraphViewport(scale, dx, dy);
|
||||
}
|
||||
|
||||
Offset toPixel(GraphPoint p) => Offset(dx + p.x * scale, dy + p.y * scale);
|
||||
}
|
||||
|
||||
/// The node id nearest [local] within [hitRadius] px, or null — the inverse of
|
||||
/// [GraphViewport], so it matches what [GraphPainter] drew.
|
||||
String? hitTestNode(
|
||||
VaultGraph graph,
|
||||
Map<String, GraphPoint> positions,
|
||||
Offset local,
|
||||
Size size, {
|
||||
Size layoutSize = const Size(800, 600),
|
||||
double hitRadius = 12,
|
||||
double zoom = 1,
|
||||
Offset pan = Offset.zero,
|
||||
}) {
|
||||
if (positions.isEmpty) return null;
|
||||
final vp = GraphViewport.fit(size, layoutSize, zoom: zoom, pan: pan);
|
||||
String? best;
|
||||
var bestD = hitRadius;
|
||||
for (final n in graph.nodes) {
|
||||
final p = positions[n.id];
|
||||
if (p == null) continue;
|
||||
final d = (vp.toPixel(p) - local).distance;
|
||||
if (d <= bestD) {
|
||||
bestD = d;
|
||||
best = n.id;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
class GraphPainter extends CustomPainter {
|
||||
GraphPainter({
|
||||
required this.graph,
|
||||
required this.positions,
|
||||
required this.tokens,
|
||||
this.highlight,
|
||||
this.layoutSize = const Size(800, 600),
|
||||
this.zoom = 1,
|
||||
this.pan = Offset.zero,
|
||||
});
|
||||
|
||||
final VaultGraph graph;
|
||||
final Map<String, GraphPoint> positions;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// When non-null, only these node ids (a hovered node's neighbourhood) render
|
||||
/// at full strength; the rest dim.
|
||||
final Set<String>? highlight;
|
||||
|
||||
final Size layoutSize;
|
||||
|
||||
/// User pan/zoom over the base fit — kept in lockstep with [hitTestNode].
|
||||
final double zoom;
|
||||
final Offset pan;
|
||||
|
||||
static const double nodeRadius = 5;
|
||||
|
||||
@override
|
||||
void paint(ui.Canvas canvas, Size size) {
|
||||
if (graph.isEmpty || positions.isEmpty) return;
|
||||
final vp = GraphViewport.fit(size, layoutSize, zoom: zoom, pan: pan);
|
||||
Offset at(GraphPoint p) => vp.toPixel(p);
|
||||
bool lit(String id) => highlight == null || highlight!.contains(id);
|
||||
|
||||
final edge = Paint()
|
||||
..strokeWidth = 1
|
||||
..color = tokens.dividerColor;
|
||||
final edgeDim = Paint()
|
||||
..strokeWidth = 1
|
||||
..color = tokens.dividerColor.withValues(alpha: 0.2);
|
||||
for (final e in graph.edges) {
|
||||
final a = positions[e.from], b = positions[e.to];
|
||||
if (a == null || b == null) continue;
|
||||
canvas.drawLine(at(a), at(b), lit(e.from) && lit(e.to) ? edge : edgeDim);
|
||||
}
|
||||
|
||||
for (final n in graph.nodes) {
|
||||
final p = positions[n.id];
|
||||
if (p == null) continue;
|
||||
final o = at(p);
|
||||
final on = lit(n.id);
|
||||
canvas.drawCircle(o, nodeRadius, Paint()..color = on ? tokens.globalForeground : tokens.globalTextMuted.withValues(alpha: 0.35));
|
||||
if (on) _label(canvas, n.label, o);
|
||||
}
|
||||
}
|
||||
|
||||
void _label(ui.Canvas canvas, String text, Offset o) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(fontSize: 10, color: tokens.globalForeground),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
ellipsis: '…',
|
||||
)..layout(maxWidth: 120);
|
||||
tp.paint(canvas, Offset(o.dx - tp.width / 2, o.dy + nodeRadius + 2));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(GraphPainter old) =>
|
||||
!identical(old.graph, graph) ||
|
||||
!identical(old.positions, positions) ||
|
||||
old.highlight != highlight ||
|
||||
old.tokens != tokens ||
|
||||
old.zoom != zoom ||
|
||||
old.pan != pan;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/// The vault link-graph context panel (T-323): loads the whole vault's link
|
||||
/// graph from pql via [GraphController] and renders the filtered [GraphView],
|
||||
/// wiring a node click to open that note in the editor.
|
||||
///
|
||||
/// Above the graph sits a filter bar — a path glob (re-queries pql on submit),
|
||||
/// a depth-from-active selector (the local graph around the open note), and
|
||||
/// tri-state tag pills (neutral → include → exclude). Loading / error / empty
|
||||
/// states show until there's a graph; a debounced refresh keeps the existing
|
||||
/// graph on screen rather than flashing back to the spinner.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/graph/src/graph_controller.dart';
|
||||
import 'package:clide/builtin/graph/src/graph_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/graph/graph_filter.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GraphPanel extends StatefulWidget {
|
||||
const GraphPanel({super.key});
|
||||
|
||||
@override
|
||||
State<GraphPanel> createState() => _GraphPanelState();
|
||||
}
|
||||
|
||||
class _GraphPanelState extends State<GraphPanel> {
|
||||
GraphController? _controller;
|
||||
DaemonClient? _ipc;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_ipc = kernel.ipc;
|
||||
_controller = GraphController(ipc: kernel.ipc, events: kernel.events);
|
||||
unawaited(_controller!.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(String nodeId) => unawaited(_ipc?.request('editor.open', args: {'path': nodeId}));
|
||||
|
||||
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.graph', placeholder: fallback);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
// Before we have any graph, the pre-load states own the whole panel.
|
||||
if (c.graph.isEmpty) {
|
||||
if (c.loading) return const Center(child: ClideSpinner(size: 20, semanticLabel: 'Loading graph'));
|
||||
if (c.error != null) return _message(ClideSettings.theme.of(context).surface.statusError, c.error!);
|
||||
return _message(null, _t('graph.empty', 'No linked notes in this vault.'));
|
||||
}
|
||||
final visible = c.visibleGraph;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_GraphFilterBar(controller: c, t: _t),
|
||||
Expanded(
|
||||
child: visible.isEmpty
|
||||
? _message(
|
||||
null,
|
||||
c.filter.depth != null && c.activePath == null
|
||||
? _t('graph.empty.nolocal', 'Open a note to see its local graph.')
|
||||
: _t('graph.empty.filtered', 'No notes match the filter.'),
|
||||
)
|
||||
: GraphView(graph: visible, onOpen: _open),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _message(Color? color, String text) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(text, color: color, muted: color == null, fontSize: clideFontCaption, textAlign: TextAlign.center),
|
||||
);
|
||||
}
|
||||
|
||||
/// The glob + depth + tag controls above the graph. Reads/writes the
|
||||
/// controller's glob and [GraphFilter]; keeps no state of its own.
|
||||
class _GraphFilterBar extends StatelessWidget {
|
||||
const _GraphFilterBar({required this.controller, required this.t});
|
||||
|
||||
final GraphController controller;
|
||||
final String Function(String key, String fallback) t;
|
||||
|
||||
static const _depths = <(String, int?)>[('All', null), ('1', 1), ('2', 2), ('3', 3)];
|
||||
|
||||
void _setDepth(int? depth) => controller.setFilter(controller.filter.copyWith(depth: depth, clearDepth: depth == null));
|
||||
|
||||
void _cycleTag(String tag) {
|
||||
final f = controller.filter;
|
||||
final inc = {...f.includeTags}, exc = {...f.excludeTags};
|
||||
if (inc.contains(tag)) {
|
||||
inc.remove(tag);
|
||||
exc.add(tag); // include → exclude
|
||||
} else if (exc.contains(tag)) {
|
||||
exc.remove(tag); // exclude → neutral
|
||||
} else {
|
||||
inc.add(tag); // neutral → include
|
||||
}
|
||||
controller.setFilter(f.copyWith(includeTags: inc, excludeTags: exc));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final f = controller.filter;
|
||||
final tags = controller.availableTags;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideFilterBox(
|
||||
hint: t('graph.filter.glob', 'Path glob, e.g. notes/**'),
|
||||
showIcon: true,
|
||||
icon: PhosphorIcons.byName('funnel'),
|
||||
onChanged: (_) {}, // reload is heavy (1+N pql calls) — apply on submit only
|
||||
onSubmitted: controller.setGlob,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(t('graph.filter.depth', 'Local'), fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
for (final (label, depth) in _depths)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: _pill(
|
||||
tokens,
|
||||
label: label == 'All' ? t('graph.filter.all', 'All') : label,
|
||||
selected: f.depth == depth,
|
||||
onTap: () => _setDepth(depth),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10, right: 10, top: 6),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
_pill(
|
||||
tokens,
|
||||
label: f.excludeTags.contains(tag) ? '−$tag' : (f.includeTags.contains(tag) ? '+$tag' : tag),
|
||||
selected: f.includeTags.contains(tag),
|
||||
danger: f.excludeTags.contains(tag),
|
||||
onTap: () => _cycleTag(tag),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pill(SurfaceTokens tokens, {required String label, required bool selected, bool danger = false, required VoidCallback onTap}) {
|
||||
final accent = danger ? tokens.statusError : tokens.globalFocus;
|
||||
final active = selected || danger;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? accent.withValues(alpha: 0.16) : (hovered ? tokens.sidebarItemHover : null),
|
||||
border: Border.all(color: active ? accent : tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: ClideText(label, fontSize: clideFontCaption, color: active ? accent : tokens.globalForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/// The interactive vault-graph view (T-323): lays a [VaultGraph] out with the
|
||||
/// force solver, paints it via [GraphPainter], and wires hover (highlight the
|
||||
/// neighbourhood) + click (open the note). Pan/zoom + filtering layer on later.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/graph/src/graph_painter.dart';
|
||||
import 'package:clide/src/graph/force_layout.dart';
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
import 'package:clide/widgets/src/clide_settings.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GraphView extends StatefulWidget {
|
||||
const GraphView({super.key, required this.graph, this.onOpen, this.layoutSize = const Size(800, 600)});
|
||||
|
||||
final VaultGraph graph;
|
||||
|
||||
/// Called with a node's id (its vault-relative path) when it's clicked.
|
||||
final void Function(String nodeId)? onOpen;
|
||||
|
||||
final Size layoutSize;
|
||||
|
||||
@override
|
||||
State<GraphView> createState() => _GraphViewState();
|
||||
}
|
||||
|
||||
class _GraphViewState extends State<GraphView> {
|
||||
static const double _minZoom = 0.2, _maxZoom = 5;
|
||||
|
||||
late Map<String, GraphPoint> _pos;
|
||||
String? _hovered;
|
||||
double _zoom = 1;
|
||||
Offset _pan = Offset.zero;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_relayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GraphView old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!identical(old.graph, widget.graph)) _relayout();
|
||||
}
|
||||
|
||||
void _relayout() {
|
||||
_pos = ForceLayout.compute(
|
||||
[for (final n in widget.graph.nodes) n.id],
|
||||
widget.graph.edgePairs,
|
||||
width: widget.layoutSize.width,
|
||||
height: widget.layoutSize.height,
|
||||
);
|
||||
_hovered = null;
|
||||
// A fresh graph re-fits; drop any user pan/zoom.
|
||||
_zoom = 1;
|
||||
_pan = Offset.zero;
|
||||
}
|
||||
|
||||
void _onScroll(PointerScrollEvent e) {
|
||||
final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9;
|
||||
final next = (_zoom * factor).clamp(_minZoom, _maxZoom);
|
||||
if (next != _zoom) setState(() => _zoom = next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final size = constraints.biggest;
|
||||
String? hit(Offset local) => hitTestNode(widget.graph, _pos, local, size, layoutSize: widget.layoutSize, zoom: _zoom, pan: _pan);
|
||||
return Listener(
|
||||
onPointerSignal: (s) {
|
||||
if (s is PointerScrollEvent) _onScroll(s);
|
||||
},
|
||||
child: MouseRegion(
|
||||
onHover: (e) {
|
||||
final h = hit(e.localPosition);
|
||||
if (h != _hovered) setState(() => _hovered = h);
|
||||
},
|
||||
onExit: (_) {
|
||||
if (_hovered != null) setState(() => _hovered = null);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTapUp: (d) {
|
||||
final h = hit(d.localPosition);
|
||||
if (h != null) widget.onOpen?.call(h);
|
||||
},
|
||||
onPanUpdate: (d) => setState(() => _pan += d.delta),
|
||||
child: CustomPaint(
|
||||
size: size,
|
||||
painter: GraphPainter(
|
||||
graph: widget.graph,
|
||||
positions: _pos,
|
||||
tokens: tokens,
|
||||
highlight: _hovered == null ? null : widget.graph.neighborhood(_hovered!),
|
||||
layoutSize: widget.layoutSize,
|
||||
zoom: _zoom,
|
||||
pan: _pan,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:clide/builtin/tools_settings/src/path_preset_control.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
@@ -7,6 +8,9 @@ import 'package:clide/src/env/supporter_binaries.dart';
|
||||
/// claude, d2, … (D-104 / T-495). A path field per tool (`app.tools.<name>`),
|
||||
/// auto-detected on first run, plus a Re-detect action. Editing a path rebuilds
|
||||
/// the live resolver so the change takes effect without a restart.
|
||||
///
|
||||
/// Also hosts the per-workspace PATH preset editor (D-106 / T-511) — the UI
|
||||
/// half of `clide env path …`.
|
||||
class ToolsSettingsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.tools-settings';
|
||||
@@ -87,9 +91,31 @@ class ToolsSettingsExtension extends ClideExtension {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Per-workspace PATH preset (D-106, T-511): dirs prepended to the
|
||||
// PATH of every shell clide spawns for this repo. CLI parity:
|
||||
// `clide env path list|set|add|remove|clear|capture`.
|
||||
SettingsSection(
|
||||
label: 'Workspace PATH',
|
||||
labelKey: 'settings.section.path',
|
||||
fields: [
|
||||
SettingsField(
|
||||
// Placeholder key — the custom control persists to the
|
||||
// worktree-aware app.env.pathPrepend.<hash> key itself.
|
||||
key: 'app.env.pathPrepend',
|
||||
kind: SettingsFieldKind.custom,
|
||||
label: 'Prepend to PATH',
|
||||
labelKey: 'settings.field.pathPreset.label',
|
||||
help:
|
||||
'Directories put ahead of PATH in Claude sessions and terminal panes spawned for this repo; worktrees share the repo\'s preset. Applies to new shells.',
|
||||
helpKey: 'settings.field.pathPreset.help',
|
||||
customId: 'tools.path-preset',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsControlContribution(id: 'tools.path-preset', customId: 'tools.path-preset', builder: (_) => const PathPresetControl()),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _redetect(List<String> args) async {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import 'dart:io' show Directory, Platform;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel;
|
||||
import 'package:clide/src/env/path_preset.dart';
|
||||
import 'package:clide/src/env/shell_env.dart' show loginShellPathOrNull;
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const _ns = 'builtin.tools-settings';
|
||||
|
||||
String _s(BuildContext context, String key, String placeholder) => ClideSettings.i18n.string(context, key, namespace: _ns, placeholder: placeholder);
|
||||
|
||||
/// Settings control for the per-workspace PATH preset (D-106, T-511): an
|
||||
/// ordered list of directories prepended to the PATH of everything clide
|
||||
/// spawns for this repo — hosted Claude sessions and terminal panes alike.
|
||||
///
|
||||
/// The preset keys off the REPO identity ([presetRootFor]): opened from a
|
||||
/// linked worktree (e.g. `.worktrees/<name>`) the control edits the main
|
||||
/// repo's preset and says so. Writes go to the user-scope settings key the
|
||||
/// `clide env path` verbs use, published on [envPathChannel] (D-6 parity);
|
||||
/// the store notifier keeps this control live for CLI edits.
|
||||
class PathPresetControl extends StatefulWidget {
|
||||
const PathPresetControl({super.key});
|
||||
|
||||
@override
|
||||
State<PathPresetControl> createState() => _PathPresetControlState();
|
||||
}
|
||||
|
||||
class _PathPresetControlState extends State<PathPresetControl> {
|
||||
final TextEditingController _entry = TextEditingController();
|
||||
final FocusNode _focus = FocusNode(debugLabel: 'add-path-entry');
|
||||
SettingsStore? _settings;
|
||||
|
||||
/// Capture-from-login-shell results; null until the button is pressed.
|
||||
List<String>? _suggestions;
|
||||
bool _addRejected = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final settings = ClideKernel.maybeOf(context)?.settings;
|
||||
if (identical(settings, _settings)) return;
|
||||
_settings?.removeListener(_onChange);
|
||||
_settings = settings;
|
||||
_settings?.addListener(_onChange);
|
||||
}
|
||||
|
||||
void _onChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settings?.removeListener(_onChange);
|
||||
_entry.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<String> _dirs(KernelServices services, String cwd) => presetDirsFrom((k) => services.settings.get<Object>(k), cwd);
|
||||
|
||||
Future<void> _write(KernelServices services, String cwd, List<String> dirs, String action) async {
|
||||
final key = pathPresetKey(cwd);
|
||||
final write = dirs.isEmpty ? services.settings.removeAt(SettingsScope.app, key) : services.settings.setAt(SettingsScope.app, key, dirs);
|
||||
services.messages.publish('ui', envPathChannel, {'action': action, 'root': presetRootFor(cwd), 'dirs': dirs});
|
||||
await write;
|
||||
}
|
||||
|
||||
/// Expand a leading `~/`, require an absolute path (mirrors the `env path`
|
||||
/// verb's guard), strip trailing slashes. Null = rejected.
|
||||
String? _normalizeEntry(String raw) {
|
||||
var d = raw.trim();
|
||||
if (d == '~' || d.startsWith('~/')) {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null || home.isEmpty) return null;
|
||||
d = d == '~' ? home : '$home${d.substring(1)}';
|
||||
}
|
||||
if (d.isEmpty || !(d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d))) return null;
|
||||
// One dir per entry — an embedded PATH separator would smuggle extra
|
||||
// (possibly empty → CWD) tokens into the joined PATH.
|
||||
final body = RegExp(r'^[A-Za-z]:').hasMatch(d) ? d.substring(2) : d;
|
||||
if (body.contains(':') || body.contains(';')) return null;
|
||||
while (d.length > 1 && d.endsWith('/')) {
|
||||
d = d.substring(0, d.length - 1);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
Future<void> _add(KernelServices services, String cwd, [String? suggestion]) async {
|
||||
final raw = suggestion ?? _entry.text;
|
||||
if (raw.trim().isEmpty) return;
|
||||
final d = _normalizeEntry(raw);
|
||||
if (d == null) {
|
||||
setState(() => _addRejected = true);
|
||||
return;
|
||||
}
|
||||
final dirs = _dirs(services, cwd);
|
||||
if (suggestion == null) _entry.clear();
|
||||
setState(() {
|
||||
_addRejected = false;
|
||||
_suggestions?.remove(raw);
|
||||
_suggestions?.remove(d);
|
||||
});
|
||||
if (dirs.contains(d)) return;
|
||||
await _write(services, cwd, [...dirs, d], 'add');
|
||||
}
|
||||
|
||||
Future<void> _move(KernelServices services, String cwd, int index, int delta) async {
|
||||
final dirs = _dirs(services, cwd);
|
||||
final to = index + delta;
|
||||
if (to < 0 || to >= dirs.length) return;
|
||||
final out = [...dirs];
|
||||
final d = out.removeAt(index);
|
||||
out.insert(to, d);
|
||||
await _write(services, cwd, out, 'set');
|
||||
}
|
||||
|
||||
Future<void> _remove(KernelServices services, String cwd, String dir) async {
|
||||
final kept = _dirs(services, cwd).where((d) => d != dir).toList();
|
||||
await _write(services, cwd, kept, 'remove');
|
||||
}
|
||||
|
||||
void _capture(List<String> current) {
|
||||
final missing = missingLoginShellDirs(loginPath: loginShellPathOrNull(), processPath: Platform.environment['PATH'] ?? '');
|
||||
setState(() => _suggestions = missing.where((d) => !current.contains(d)).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final services = ClideKernel.maybeOf(context);
|
||||
final cwd = services?.settings.projectDir?.path;
|
||||
if (services == null || cwd == null) {
|
||||
return ClideText(_s(context, 'path.noWorkspace', 'Open a workspace to set its PATH preset.'), fontSize: clideFontCaption, color: tokens.globalTextMuted);
|
||||
}
|
||||
|
||||
final root = presetRootFor(cwd);
|
||||
final dirs = _dirs(services, cwd);
|
||||
final suggestions = _suggestions;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (root != cwd)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(_s(context, 'path.sharedRoot', 'Worktree — preset shared with'), fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
root,
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dirs.isEmpty)
|
||||
ClideText(
|
||||
_s(context, 'path.empty', 'No preset entries — spawned shells get the resolved login-shell PATH as-is.'),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalTextMuted,
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < dirs.length; i++) _row(context, services, cwd, tokens, dirs, i),
|
||||
const SizedBox(height: 10),
|
||||
_addRow(context, services, cwd, tokens),
|
||||
if (_addRejected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: ClideText(_s(context, 'path.invalid', 'Enter an absolute path (or ~/…).'), fontSize: clideFontCaption, color: tokens.statusError),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_captureButton(context, tokens, dirs),
|
||||
if (suggestions != null && suggestions.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: ClideText(
|
||||
_s(context, 'path.captureNone', 'Nothing to suggest — the login-shell PATH is already covered.'),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
if (suggestions != null)
|
||||
for (final d in suggestions) _suggestionRow(context, services, cwd, tokens, d),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens, List<String> dirs, int index) {
|
||||
final dir = dirs[index];
|
||||
final exists = Directory(dir).existsSync();
|
||||
final removeLabel = _s(context, 'path.remove', 'Remove');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
dir,
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: exists ? tokens.globalForeground : tokens.statusWarning,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (!exists) ...[
|
||||
const SizedBox(width: 6),
|
||||
ClideText(_s(context, 'path.missing', 'missing'), fontSize: clideFontCaption, color: tokens.statusWarning),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
_iconButton(context, 'arrow-up', '${_s(context, 'path.moveUp', 'Move up')}: $dir', index == 0 ? null : () => _move(services, cwd, index, -1)),
|
||||
const SizedBox(width: 6),
|
||||
_iconButton(
|
||||
context,
|
||||
'arrow-down',
|
||||
'${_s(context, 'path.moveDown', 'Move down')}: $dir',
|
||||
index == dirs.length - 1 ? null : () => _move(services, cwd, index, 1),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_iconButton(context, 'trash', '$removeLabel: $dir', () => _remove(services, cwd, dir), color: tokens.statusError),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _suggestionRow(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens, String dir) {
|
||||
final addLabel = _s(context, 'path.addSuggestion', 'Add suggested entry');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconButton(context, 'plus-circle', '$addLabel: $dir', () => _add(services, cwd, dir), color: tokens.statusSuccess),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(dir, fontSize: clideFontMono, fontFamily: ClideSettings.fonts.monoOf(context), muted: true, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final enabledColor = color ?? tokens.globalForeground;
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: onTap != null,
|
||||
label: semantic,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.byName(icon), size: 14, color: onTap == null ? tokens.globalTextMuted : (hovered ? enabledColor : tokens.globalTextMuted)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _addRow(BuildContext context, KernelServices services, String cwd, SurfaceTokens tokens) {
|
||||
final addLabel = _s(context, 'path.add', 'Add entry');
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 26,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _entry,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) => _add(services, cwd),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: addLabel,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => _add(services, cwd),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.buttonHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
|
||||
child: ClideText(addLabel, color: tokens.buttonForeground, fontSize: clideFontCaption),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _captureButton(BuildContext context, SurfaceTokens tokens, List<String> dirs) {
|
||||
final label = _s(context, 'path.capture', 'Suggest from login shell');
|
||||
return Row(
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => _capture(dirs),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : tokens.panelBackground,
|
||||
border: Border.all(color: tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(label, color: tokens.globalForeground, fontSize: clideFontCaption),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
|
||||
/// The hand-built helper binary the probe spawns. Not part of the repo — the
|
||||
/// harness skips this probe when it is absent.
|
||||
const fdCheckHelperPath = '/tmp/checkfd';
|
||||
|
||||
/// Probe whether `Process.start` inherits socket fds (the macOS question the
|
||||
/// testmode harness answers). Returns a human-readable result line.
|
||||
Future<String> fdInheritanceCheck() async {
|
||||
@@ -20,7 +24,7 @@ Future<String> fdInheritanceCheck() async {
|
||||
final parent = sv[0];
|
||||
final child = sv[1];
|
||||
pkg_ffi.calloc.free(sv);
|
||||
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||
final proc = await Process.start(fdCheckHelperPath, [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||
final exit = await proc.exitCode;
|
||||
libc.close(parent);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
|
||||
library;
|
||||
|
||||
/// Mirrors [fd_check_io.dart]; never exists on web, so the probe is skipped.
|
||||
const fdCheckHelperPath = '/tmp/checkfd';
|
||||
|
||||
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// The single dispatch point for opening a workspace file the way
|
||||
/// clide routes file activations (T-51 / T-187):
|
||||
/// * `.md` paths → the markdown reader, via the kernel MessageBus;
|
||||
/// * `.canvas` paths → the canvas pane, via the kernel MessageBus (T-322);
|
||||
/// * every other path → the editor, via the `editor.open` IPC verb.
|
||||
///
|
||||
/// Records the open in [KernelServices.recentFiles] so the quick-open
|
||||
@@ -15,8 +16,11 @@ import 'package:clide/kernel/src/facade.dart';
|
||||
void openWorkspaceFile(KernelServices services, String path) {
|
||||
if (path.isEmpty) return;
|
||||
services.recentFiles.push(path);
|
||||
if (path.toLowerCase().endsWith('.md')) {
|
||||
final lower = path.toLowerCase();
|
||||
if (lower.endsWith('.md')) {
|
||||
services.messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
} else if (lower.endsWith('.canvas')) {
|
||||
services.messages.publish('builtin.canvas', 'selection', {'path': path});
|
||||
} else {
|
||||
unawaited(services.ipc.request('editor.open', args: {'path': path}));
|
||||
}
|
||||
|
||||
@@ -282,7 +282,14 @@ void _emit(StringBuffer buf, Object? v, int indent) {
|
||||
return;
|
||||
}
|
||||
v.forEach((k, vv) {
|
||||
buf.write('$pad$k:');
|
||||
// Keys go through the same quoting as values: a numeric-shaped key —
|
||||
// e.g. an all-digit FNV workspace-hash suffix (`app.env.pathPrepend.
|
||||
// 0123…`, `app.claude.account.<hash>`) — would otherwise reload as an
|
||||
// int/float and silently corrupt the key (leading zero dropped, or
|
||||
// `1e…` collapsing to Infinity), orphaning the stored value.
|
||||
buf.write(pad);
|
||||
_emitScalar(buf, '$k');
|
||||
buf.write(':');
|
||||
if (vv is Map && vv.isNotEmpty) {
|
||||
buf.writeln();
|
||||
_emit(buf, vv, indent + 1);
|
||||
|
||||
+46
-2
@@ -40,6 +40,7 @@ import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/clide.dart' show clideVersion;
|
||||
import 'package:clide/src/daemon/claude_account_commands.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/env_path_commands.dart';
|
||||
import 'package:clide/src/daemon/draw_commands.dart';
|
||||
import 'package:clide/src/draw/compare_template.dart' show compareTemplateHandler;
|
||||
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
|
||||
@@ -62,7 +63,8 @@ import 'package:clide/src/daemon/search_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath;
|
||||
import 'package:clide/src/env/path_preset.dart' show applyPathPreset, pathPresetKey, presetDirsFrom, presetLookupRoot;
|
||||
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath, resolvedToolPath;
|
||||
import 'package:clide/src/env/supporter_binaries.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart' show kPhosphorGlyphs;
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
@@ -310,7 +312,19 @@ Future<void> main() async {
|
||||
crumbPath: '${logDirectory()}/clide-pty.crumbs.log',
|
||||
verbose: log.minLevel.index <= LogLevel.debug.index,
|
||||
);
|
||||
final paneRegistry = PaneRegistry(events: eventSink, ptyLog: ptyLog);
|
||||
// Terminal PTY children get the workspace's PATH preset (D-106) prepended
|
||||
// to the resolved login-shell PATH. Read live per spawn off the kernel
|
||||
// settings (null pre-boot → plain resolved PATH, same as before).
|
||||
final paneRegistry = PaneRegistry(
|
||||
events: eventSink,
|
||||
ptyLog: ptyLog,
|
||||
pathForSpawn: (cwd) {
|
||||
final settings = kernelSettings;
|
||||
final base = resolvedToolPath();
|
||||
if (settings == null) return base;
|
||||
return applyPathPreset(base, presetDirsFrom((k) => settings.get<Object>(k), presetLookupRoot(cwd, workRoot.path)));
|
||||
},
|
||||
);
|
||||
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
|
||||
// visible to `pane list` by snapshotting the kernel PanelRegistry +
|
||||
// LayoutArrangement at request time — no mirrored state to drift.
|
||||
@@ -440,6 +454,18 @@ Future<void> main() async {
|
||||
publisher: () => kernelMessages?.publish,
|
||||
workspaceCwd: () => workRoot.path,
|
||||
);
|
||||
// `clide env path …` — the per-workspace PATH preset's CLI half (D-106,
|
||||
// T-511). Reads/writes the user-scope preset key through the kernel
|
||||
// settings; mutations publish on envPathChannel.
|
||||
registerEnvPathCommands(
|
||||
dispatcher,
|
||||
() {
|
||||
final settings = kernelSettings;
|
||||
return settings == null ? null : _PathPresetStoreAdapter(settings);
|
||||
},
|
||||
publisher: () => kernelMessages?.publish,
|
||||
workspaceCwd: () => workRoot.path,
|
||||
);
|
||||
// `clide status` — one-shot orientation snapshot (T-221): active pane,
|
||||
// focused file + selection, git summary, layout. Assembled here where the
|
||||
// live kernel + subsystem state is in scope; the reader's viewed doc is
|
||||
@@ -655,6 +681,24 @@ class _AccountStoreAdapter implements AccountStore {
|
||||
Future<void> unbind(String cwd) => _reg.unbindWorkspace(cwd);
|
||||
}
|
||||
|
||||
/// Adapts the (foundation-bound) [SettingsStore] to the Flutter-free
|
||||
/// [PathPresetStore] port the `env path` verbs use (D-106, T-511). The key is
|
||||
/// worktree-aware ([pathPresetKey]); an empty preset removes the key rather
|
||||
/// than leaving an empty list in settings.yaml.
|
||||
class _PathPresetStoreAdapter implements PathPresetStore {
|
||||
_PathPresetStoreAdapter(this._settings);
|
||||
final SettingsStore _settings;
|
||||
|
||||
@override
|
||||
List<String> dirsFor(String cwd) => presetDirsFrom((k) => _settings.get<Object>(k), cwd);
|
||||
|
||||
@override
|
||||
Future<void> setFor(String cwd, List<String> dirs) {
|
||||
final key = pathPresetKey(cwd);
|
||||
return dirs.isEmpty ? _settings.removeAt(SettingsScope.app, key) : _settings.setAt(SettingsScope.app, key, dirs);
|
||||
}
|
||||
}
|
||||
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/// The Obsidian JSONCanvas format (`.canvas` files) as a pure Dart model
|
||||
/// (T-322). Parses the on-disk JSON into typed nodes + edges and serialises
|
||||
/// back, so the canvas pane can load, edit, and persist a `.canvas`.
|
||||
///
|
||||
/// Per D-91 `.canvas` is an import format, not clide's native schema — this
|
||||
/// model is the faithful parse. The interactive pane paints this model
|
||||
/// directly via `CanvasPainter` (the D-103 live-widget exception, like the
|
||||
/// graph); only the display-only drawing card lowers to the SVG substrate.
|
||||
/// Pure data — no Flutter, no I/O here — so it runs under `dart test`.
|
||||
/// Spec: https://jsoncanvas.org/spec/1.0/
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
/// Which edge of a node an edge attaches to.
|
||||
enum CanvasSide {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left;
|
||||
|
||||
static CanvasSide? parse(Object? v) => switch (v) {
|
||||
'top' => top,
|
||||
'right' => right,
|
||||
'bottom' => bottom,
|
||||
'left' => left,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String get wire => name;
|
||||
}
|
||||
|
||||
/// The endpoint decoration of an edge. Obsidian defaults `fromEnd` to none and
|
||||
/// `toEnd` to arrow.
|
||||
enum CanvasEnd {
|
||||
none,
|
||||
arrow;
|
||||
|
||||
static CanvasEnd parse(Object? v, CanvasEnd fallback) => switch (v) {
|
||||
'none' => none,
|
||||
'arrow' => arrow,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
String get wire => name;
|
||||
}
|
||||
|
||||
/// How a group node's background image is laid out.
|
||||
enum CanvasBackgroundStyle {
|
||||
cover,
|
||||
ratio,
|
||||
repeat;
|
||||
|
||||
static CanvasBackgroundStyle? parse(Object? v) => switch (v) {
|
||||
'cover' => cover,
|
||||
'ratio' => ratio,
|
||||
'repeat' => repeat,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String get wire => name;
|
||||
}
|
||||
|
||||
/// A node in a canvas. Every node has an id, a position, a size, and an
|
||||
/// optional [color] (a preset `"1".."6"` or a `#rrggbb` hex, per the spec).
|
||||
sealed class CanvasNode {
|
||||
const CanvasNode({required this.id, required this.x, required this.y, required this.width, required this.height, this.color});
|
||||
|
||||
final String id;
|
||||
final double x, y, width, height;
|
||||
final String? color;
|
||||
|
||||
/// The `type` discriminator written to disk.
|
||||
String get type;
|
||||
|
||||
Map<String, Object?> toJson();
|
||||
|
||||
Map<String, Object?> _base() => {'id': id, 'type': type, 'x': x, 'y': y, 'width': width, 'height': height, if (color != null) 'color': color};
|
||||
}
|
||||
|
||||
/// A free-text node holding a markdown string.
|
||||
class TextNode extends CanvasNode {
|
||||
const TextNode({required super.id, required super.x, required super.y, required super.width, required super.height, super.color, this.text = ''});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
String get type => 'text';
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {..._base(), 'text': text};
|
||||
}
|
||||
|
||||
/// An embedded vault file, optionally scrolled to a [subpath] heading/block.
|
||||
class FileNode extends CanvasNode {
|
||||
const FileNode({
|
||||
required super.id,
|
||||
required super.x,
|
||||
required super.y,
|
||||
required super.width,
|
||||
required super.height,
|
||||
super.color,
|
||||
required this.file,
|
||||
this.subpath,
|
||||
});
|
||||
|
||||
final String file;
|
||||
final String? subpath;
|
||||
|
||||
@override
|
||||
String get type => 'file';
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {..._base(), 'file': file, if (subpath != null) 'subpath': subpath};
|
||||
}
|
||||
|
||||
/// An external URL card.
|
||||
class LinkNode extends CanvasNode {
|
||||
const LinkNode({required super.id, required super.x, required super.y, required super.width, required super.height, super.color, required this.url});
|
||||
|
||||
final String url;
|
||||
|
||||
@override
|
||||
String get type => 'link';
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {..._base(), 'url': url};
|
||||
}
|
||||
|
||||
/// A labelled rectangle that visually groups the nodes inside it. May carry a
|
||||
/// background image.
|
||||
class GroupNode extends CanvasNode {
|
||||
const GroupNode({
|
||||
required super.id,
|
||||
required super.x,
|
||||
required super.y,
|
||||
required super.width,
|
||||
required super.height,
|
||||
super.color,
|
||||
this.label,
|
||||
this.background,
|
||||
this.backgroundStyle,
|
||||
});
|
||||
|
||||
final String? label;
|
||||
final String? background;
|
||||
final CanvasBackgroundStyle? backgroundStyle;
|
||||
|
||||
@override
|
||||
String get type => 'group';
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {
|
||||
..._base(),
|
||||
if (label != null) 'label': label,
|
||||
if (background != null) 'background': background,
|
||||
if (backgroundStyle != null) 'backgroundStyle': backgroundStyle!.wire,
|
||||
};
|
||||
}
|
||||
|
||||
/// A directed connection between two nodes, optionally anchored to a specific
|
||||
/// [fromSide]/[toSide] and decorated with end caps, a [color], and a [label].
|
||||
class CanvasEdge {
|
||||
const CanvasEdge({
|
||||
required this.id,
|
||||
required this.fromNode,
|
||||
required this.toNode,
|
||||
this.fromSide,
|
||||
this.toSide,
|
||||
this.fromEnd = CanvasEnd.none,
|
||||
this.toEnd = CanvasEnd.arrow,
|
||||
this.color,
|
||||
this.label,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String fromNode, toNode;
|
||||
final CanvasSide? fromSide, toSide;
|
||||
final CanvasEnd fromEnd, toEnd;
|
||||
final String? color;
|
||||
final String? label;
|
||||
|
||||
static CanvasEdge? _parse(Map<String, Object?> m) {
|
||||
final id = m['id'], from = m['fromNode'], to = m['toNode'];
|
||||
if (id is! String || from is! String || to is! String) return null;
|
||||
return CanvasEdge(
|
||||
id: id,
|
||||
fromNode: from,
|
||||
toNode: to,
|
||||
fromSide: CanvasSide.parse(m['fromSide']),
|
||||
toSide: CanvasSide.parse(m['toSide']),
|
||||
fromEnd: CanvasEnd.parse(m['fromEnd'], CanvasEnd.none),
|
||||
toEnd: CanvasEnd.parse(m['toEnd'], CanvasEnd.arrow),
|
||||
color: m['color'] as String?,
|
||||
label: m['label'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'fromNode': fromNode,
|
||||
if (fromSide != null) 'fromSide': fromSide!.wire,
|
||||
'toNode': toNode,
|
||||
if (toSide != null) 'toSide': toSide!.wire,
|
||||
// Only emit end caps when they differ from the spec defaults.
|
||||
if (fromEnd != CanvasEnd.none) 'fromEnd': fromEnd.wire,
|
||||
if (toEnd != CanvasEnd.arrow) 'toEnd': toEnd.wire,
|
||||
if (color != null) 'color': color,
|
||||
if (label != null) 'label': label,
|
||||
};
|
||||
}
|
||||
|
||||
/// A parsed `.canvas` document: its [nodes] and [edges].
|
||||
class CanvasDoc {
|
||||
const CanvasDoc({this.nodes = const [], this.edges = const []});
|
||||
|
||||
final List<CanvasNode> nodes;
|
||||
final List<CanvasEdge> edges;
|
||||
|
||||
bool get isEmpty => nodes.isEmpty && edges.isEmpty;
|
||||
|
||||
/// Parse `.canvas` JSON text. Malformed JSON, or a top level that isn't an
|
||||
/// object, throws [FormatException]; individual nodes/edges that are
|
||||
/// unrecognised or missing required fields are skipped, not fatal.
|
||||
factory CanvasDoc.parse(String source) {
|
||||
final decoded = source.trim().isEmpty ? const <String, Object?>{} : jsonDecode(source);
|
||||
if (decoded is! Map) throw const FormatException('canvas: top level must be a JSON object');
|
||||
return CanvasDoc.fromJson(decoded.cast<String, Object?>());
|
||||
}
|
||||
|
||||
factory CanvasDoc.fromJson(Map<String, Object?> m) {
|
||||
final nodes = <CanvasNode>[];
|
||||
for (final raw in (m['nodes'] as List? ?? const [])) {
|
||||
if (raw is Map) {
|
||||
final node = _parseNode(raw.cast<String, Object?>());
|
||||
if (node != null) nodes.add(node);
|
||||
}
|
||||
}
|
||||
final edges = <CanvasEdge>[];
|
||||
for (final raw in (m['edges'] as List? ?? const [])) {
|
||||
if (raw is Map) {
|
||||
final edge = CanvasEdge._parse(raw.cast<String, Object?>());
|
||||
if (edge != null) edges.add(edge);
|
||||
}
|
||||
}
|
||||
return CanvasDoc(nodes: nodes, edges: edges);
|
||||
}
|
||||
|
||||
/// Serialise back to the on-disk shape. Empty `nodes`/`edges` arrays are
|
||||
/// always present, matching what Obsidian writes.
|
||||
Map<String, Object?> toJson() => {
|
||||
'nodes': [for (final n in nodes) n.toJson()],
|
||||
'edges': [for (final e in edges) e.toJson()],
|
||||
};
|
||||
|
||||
String encode() => const JsonEncoder.withIndent('\t').convert(toJson());
|
||||
|
||||
static CanvasNode? _parseNode(Map<String, Object?> m) {
|
||||
final id = m['id'];
|
||||
if (id is! String) return null;
|
||||
final x = _num(m['x']), y = _num(m['y']), w = _num(m['width']), h = _num(m['height']);
|
||||
if (x == null || y == null || w == null || h == null) return null;
|
||||
final color = m['color'] as String?;
|
||||
return switch (m['type']) {
|
||||
'text' => TextNode(id: id, x: x, y: y, width: w, height: h, color: color, text: m['text'] as String? ?? ''),
|
||||
'file' =>
|
||||
m['file'] is String
|
||||
? FileNode(id: id, x: x, y: y, width: w, height: h, color: color, file: m['file'] as String, subpath: m['subpath'] as String?)
|
||||
: null,
|
||||
'link' => m['url'] is String ? LinkNode(id: id, x: x, y: y, width: w, height: h, color: color, url: m['url'] as String) : null,
|
||||
'group' => GroupNode(
|
||||
id: id,
|
||||
x: x,
|
||||
y: y,
|
||||
width: w,
|
||||
height: h,
|
||||
color: color,
|
||||
label: m['label'] as String?,
|
||||
background: m['background'] as String?,
|
||||
backgroundStyle: CanvasBackgroundStyle.parse(m['backgroundStyle']),
|
||||
),
|
||||
_ => null, // unknown/unsupported node type — skip
|
||||
};
|
||||
}
|
||||
|
||||
static double? _num(Object? v) => v is num ? v.toDouble() : null;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/// Registers the `env path …` verbs — the CLI half of the per-workspace PATH
|
||||
/// preset (D-106, T-511; D-6 CLI parity with the Tools settings section).
|
||||
///
|
||||
/// `clide env path list`
|
||||
/// `clide env path set <dir> [<dir> …]`
|
||||
/// `clide env path add <dir> [<dir> …]`
|
||||
/// `clide env path remove <dir> [<dir> …]`
|
||||
/// `clide env path clear`
|
||||
/// `clide env path capture`
|
||||
///
|
||||
/// The argv grammar splits the first two tokens as `subsystem.verb`, so the
|
||||
/// command id is `env.path` and the sub-verb arrives as the first positional;
|
||||
/// the trailing `dirs` positional is variadic. Preset reads/writes go through
|
||||
/// an injected [PathPresetStore] port and mutations are published on
|
||||
/// [envPathChannel] — keeping this handler Flutter-free so it runs under
|
||||
/// `dart test`.
|
||||
///
|
||||
/// Preset dirs deliberately live OUTSIDE the workspace (`~/.nvm/…`, linuxbrew),
|
||||
/// so there is no workspace-confinement check here — the guards are absolute
|
||||
/// paths only (after `~/` expansion) and the T-104 leading-dash rejection.
|
||||
/// Nonexistent dirs warn (in the `missing` field), never error: a preset may
|
||||
/// predate the dir it names.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Directory, Platform;
|
||||
|
||||
import '../env/path_preset.dart';
|
||||
import '../env/shell_env.dart' show loginShellPathOrNull, resolvedToolPath;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
import 'ui_command.dart' show MessagePublisher;
|
||||
|
||||
/// The MessageBus channel preset mutations publish on; UI surfaces subscribe
|
||||
/// to reflect a CLI edit (the settings control also re-reads off the store
|
||||
/// notifier, so this is the event-contract half of D-6).
|
||||
const envPathChannel = 'env.path';
|
||||
|
||||
/// Flutter-free port over the (foundation-bound) SettingsStore, injected so
|
||||
/// this command runs under `dart test`. main.dart adapts the real store to it.
|
||||
abstract class PathPresetStore {
|
||||
/// The preset for the workspace at [cwd], resolved to its repo identity
|
||||
/// ([presetRootFor]) — a worktree reads its main repo's preset.
|
||||
List<String> dirsFor(String cwd);
|
||||
|
||||
/// Replace the preset for [cwd]'s repo. An empty [dirs] removes the key.
|
||||
Future<void> setFor(String cwd, List<String> dirs);
|
||||
}
|
||||
|
||||
/// Register `env.path`. [store] / [publisher] / [workspaceCwd] are late-bound
|
||||
/// closures (captured post-boot in main.dart); each may be null in a headless
|
||||
/// context, in which case the verb degrades to a clear error. [home] /
|
||||
/// [dirExists] / [loginPath] / [processPath] are test seams over the real
|
||||
/// environment.
|
||||
void registerEnvPathCommands(
|
||||
DaemonDispatcher d,
|
||||
PathPresetStore? Function() store, {
|
||||
MessagePublisher? Function()? publisher,
|
||||
String? Function()? workspaceCwd,
|
||||
String? Function()? home,
|
||||
bool Function(String dir)? dirExists,
|
||||
String? Function()? loginPath,
|
||||
String Function()? processPath,
|
||||
}) {
|
||||
d.register(
|
||||
'env.path',
|
||||
(req) async => _dispatch(
|
||||
req,
|
||||
store(),
|
||||
publisher?.call(),
|
||||
workspaceCwd?.call(),
|
||||
home: home ?? () => Platform.environment['HOME'],
|
||||
dirExists: dirExists ?? (dir) => Directory(dir).existsSync(),
|
||||
loginPath: loginPath ?? loginShellPathOrNull,
|
||||
processPath: processPath ?? () => Platform.environment['PATH'] ?? '',
|
||||
),
|
||||
schema: const CommandSchema(
|
||||
positional: ['action', 'dirs'],
|
||||
args: {
|
||||
'action': ArgSpec(required: true, rejectLeadingDash: true),
|
||||
'dirs': ArgSpec(type: ArgType.stringList, rejectLeadingDash: true, maxItems: 64),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _dispatch(
|
||||
IpcRequest req,
|
||||
PathPresetStore? store,
|
||||
MessagePublisher? publish,
|
||||
String? cwd, {
|
||||
required String? Function() home,
|
||||
required bool Function(String dir) dirExists,
|
||||
required String? Function() loginPath,
|
||||
required String Function() processPath,
|
||||
}) async {
|
||||
if (store == null) return _err(req.id, 'settings unavailable in this context');
|
||||
if (cwd == null) return _err(req.id, 'no workspace open');
|
||||
final action = (req.args['action'] as String?)?.trim();
|
||||
final rawDirs = (req.args['dirs'] as List?)?.cast<String>() ?? const <String>[];
|
||||
final root = presetRootFor(cwd);
|
||||
|
||||
Future<IpcResponse> mutate(List<String> dirs) async {
|
||||
await store.setFor(cwd, dirs);
|
||||
publish?.call('cli', envPathChannel, {'action': action, 'root': root, 'dirs': dirs});
|
||||
return _ok(req.id, {'root': root, 'dirs': dirs, 'missing': _missing(dirs, dirExists)});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
final dirs = store.dirsFor(cwd);
|
||||
return _ok(req.id, {'root': root, 'dirs': dirs, 'missing': _missing(dirs, dirExists), 'effectivePath': applyPathPreset(resolvedToolPath(), dirs)});
|
||||
|
||||
case 'set':
|
||||
if (rawDirs.isEmpty) return _err(req.id, 'set requires at least one <dir>', hint: 'to empty the preset use: clide env path clear');
|
||||
final (dirs, bad) = _expandAll(rawDirs, home());
|
||||
if (bad != null) return _err(req.id, bad, hint: 'preset entries must be absolute paths (or ~/…)');
|
||||
return mutate(_dedupe(dirs));
|
||||
|
||||
case 'add':
|
||||
if (rawDirs.isEmpty) return _err(req.id, 'add requires at least one <dir>');
|
||||
final (dirs, bad) = _expandAll(rawDirs, home());
|
||||
if (bad != null) return _err(req.id, bad, hint: 'preset entries must be absolute paths (or ~/…)');
|
||||
return mutate(_dedupe([...store.dirsFor(cwd), ...dirs]));
|
||||
|
||||
case 'remove':
|
||||
if (rawDirs.isEmpty) return _err(req.id, 'remove requires at least one <dir>');
|
||||
final (dirs, bad) = _expandAll(rawDirs, home());
|
||||
if (bad != null) return _err(req.id, bad, hint: 'pass the entry exactly as `env path list` shows it');
|
||||
final current = store.dirsFor(cwd);
|
||||
final drop = dirs.toSet();
|
||||
final kept = current.where((d) => !drop.contains(d)).toList();
|
||||
if (kept.length == current.length) {
|
||||
return _err(req.id, 'no preset entry matches ${dirs.join(", ")}', hint: 'clide env path list');
|
||||
}
|
||||
return mutate(kept);
|
||||
|
||||
case 'clear':
|
||||
return mutate(const []);
|
||||
|
||||
case 'capture':
|
||||
final dirs = store.dirsFor(cwd);
|
||||
final login = loginPath();
|
||||
final proc = processPath();
|
||||
final suggested = missingLoginShellDirs(loginPath: login, processPath: proc).where((d) => !dirs.contains(d)).toList();
|
||||
return _ok(req.id, {
|
||||
'root': root,
|
||||
'suggested': suggested,
|
||||
'loginShellPath': login,
|
||||
'processPath': proc,
|
||||
if (login == null) 'note': 'login-shell PATH probe unavailable; nothing to diff',
|
||||
});
|
||||
|
||||
default:
|
||||
return _err(
|
||||
req.id,
|
||||
'unknown env path action: ${action == null || action.isEmpty ? '(none)' : action}',
|
||||
hint: 'use: list | set | add | remove | clear | capture',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand a leading `~/` against [home] and require absolute results. Returns
|
||||
/// the expanded list, or an error message on the first bad entry.
|
||||
(List<String>, String?) _expandAll(List<String> dirs, String? home) {
|
||||
final out = <String>[];
|
||||
for (final raw in dirs) {
|
||||
var d = raw.trim();
|
||||
if (d.isEmpty) return (out, 'empty preset entry');
|
||||
if (d == '~' || d.startsWith('~/')) {
|
||||
if (home == null || home.isEmpty) return (out, 'cannot expand "~" (no HOME)');
|
||||
d = d == '~' ? home : '$home${d.substring(1)}';
|
||||
}
|
||||
final absolute = d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d);
|
||||
if (!absolute) return (out, 'not an absolute path: $raw');
|
||||
// One dir per entry: an embedded PATH separator would expand into extra
|
||||
// tokens at join time — and a stray trailing ':' yields an EMPTY token,
|
||||
// which POSIX shells resolve as CWD (the classic dot-in-PATH hazard).
|
||||
final body = RegExp(r'^[A-Za-z]:').hasMatch(d) ? d.substring(2) : d;
|
||||
if (body.contains(':') || body.contains(';')) return (out, 'entry contains a PATH separator: $raw');
|
||||
while (d.length > 1 && d.endsWith('/')) {
|
||||
d = d.substring(0, d.length - 1);
|
||||
}
|
||||
out.add(d);
|
||||
}
|
||||
return (out, null);
|
||||
}
|
||||
|
||||
List<String> _dedupe(List<String> dirs) {
|
||||
final out = <String>[];
|
||||
for (final d in dirs) {
|
||||
if (!out.contains(d)) out.add(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<String> _missing(List<String> dirs, bool Function(String) dirExists) => [
|
||||
for (final d in dirs)
|
||||
if (!dirExists(d)) d,
|
||||
];
|
||||
|
||||
IpcResponse _ok(String id, Map<String, Object?> data) => IpcResponse.ok(id: id, data: data);
|
||||
|
||||
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
@@ -84,7 +84,7 @@ void _writeScaffold(String dir, String name) {
|
||||
// Claude with an empty CLAUDE.md stub. No language/framework templates. Never
|
||||
// clobbers — safe to run over an existing folder (T-489).
|
||||
final gitignore = File('$dir/.gitignore');
|
||||
if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n');
|
||||
if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n.worktrees/\n');
|
||||
final claudeMd = File('$dir/CLAUDE.md');
|
||||
if (!claudeMd.existsSync()) claudeMd.writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n');
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ typedef MessagePublisher = void Function(String publisher, String channel, Map<S
|
||||
|
||||
/// The readers `ui.open` can target → (bus publisher id, payload key the
|
||||
/// reader reads its entry from). Matches each reader's `ReaderNav` dataKey
|
||||
/// (tickets/decisions key on `id`; markdown on `path`). `diff` is not a
|
||||
/// ReaderNav reader — it keys on `path` and its extension subscribes to the
|
||||
/// same `selection` channel to reveal its tab + focus the file (T-233).
|
||||
/// (tickets/decisions key on `id`; markdown on `path`). `diff` and `canvas`
|
||||
/// are not ReaderNav readers — they key on `path` and their extensions
|
||||
/// subscribe to the same `selection` channel to reveal their tab + focus
|
||||
/// the file (T-233, T-322).
|
||||
const Map<String, ({String publisher, String dataKey})> _readers = {
|
||||
'tickets': (publisher: 'builtin.tickets', dataKey: 'id'),
|
||||
'decisions': (publisher: 'builtin.decisions', dataKey: 'id'),
|
||||
'markdown': (publisher: 'builtin.markdown', dataKey: 'path'),
|
||||
'diff': (publisher: 'builtin.diff', dataKey: 'path'),
|
||||
'canvas': (publisher: 'builtin.canvas', dataKey: 'path'),
|
||||
};
|
||||
|
||||
/// Severities the toast verb accepts — mirrors `ToastSeverity` (kept as a
|
||||
|
||||
Vendored
+176
@@ -0,0 +1,176 @@
|
||||
/// Per-workspace PATH preset (D-106, T-511) — a machine-local, user-scope list
|
||||
/// of directories prepended to the PATH clide hands to every shell it spawns:
|
||||
/// the hosted Claude session (so the agent's Bash tool sees it) and terminal
|
||||
/// PTY panes. The T-439 login-shell probe is a global heuristic with a known
|
||||
/// hole (interactive-only profile additions like brew shellenv in `~/.bashrc`);
|
||||
/// the preset is the explicit per-repo layer on top of it.
|
||||
///
|
||||
/// Storage is the app (user) settings layer keyed by workspace hash — the
|
||||
/// account-binding pattern (T-483) — so absolute machine paths never land in a
|
||||
/// committed file. The hash keys off the REPO identity, not the literal
|
||||
/// directory: a linked git worktree (its `.git` is a `gitdir:` pointer file,
|
||||
/// e.g. under an in-repo `.worktrees/` dir) resolves to the main repo root, so
|
||||
/// every worktree shares its repo's preset.
|
||||
///
|
||||
/// Security fence: the preset feeds spawned-shell environments only. Clide's
|
||||
/// own binary resolution (D-104 pins, bundled pql/git, the toolchain probe)
|
||||
/// never consults it — see D-106.
|
||||
///
|
||||
/// Flutter-free (consumed by the `env path` daemon verbs under `dart test`).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
|
||||
|
||||
/// Prefix of the user-scope settings key; the suffix is the preset-root hash.
|
||||
const pathPresetKeyPrefix = 'app.env.pathPrepend.';
|
||||
|
||||
/// The settings key holding [workspaceRoot]'s preset — app layer, suffixed
|
||||
/// with the FNV-1a hash (the same one D-70 derives for the socket path) of the
|
||||
/// [presetRootFor]-resolved repo root, so all worktrees of a repo share one key.
|
||||
String pathPresetKey(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile, bool Function(String path)? isDir}) =>
|
||||
'$pathPresetKeyPrefix${fnv1a64Hex(canonicalWorkspaceKey(presetRootFor(workspaceRoot, isFile: isFile, readFile: readFile, isDir: isDir)))}';
|
||||
|
||||
/// The directory whose identity keys the preset: the MAIN repo root when
|
||||
/// [workspaceRoot] is a linked git worktree, else [workspaceRoot] itself
|
||||
/// (trailing separators stripped, so `/repo` and `/repo/` map alike).
|
||||
///
|
||||
/// A linked worktree's `.git` is a pointer FILE — `gitdir: <main>/.git/worktrees/<name>`
|
||||
/// (git writes forward slashes on every platform; a relative
|
||||
/// gitdir is resolved against the worktree root). Anything that doesn't match
|
||||
/// that shape — a normal repo (`.git` directory), no `.git` at all, a
|
||||
/// submodule pointer — keys off [workspaceRoot] unchanged.
|
||||
///
|
||||
/// The pointer content is REPO-controlled, so the resolved target is
|
||||
/// validated before it is trusted: the candidate main root must actually
|
||||
/// hold a `.git` directory (a genuine repo), else the pointer is ignored and
|
||||
/// the workspace keys off itself. Without that check a crafted `.git` file
|
||||
/// could alias an arbitrary path's preset key (same-user only — the preset
|
||||
/// values themselves stay user-authored — but the boundary is cheap to hold).
|
||||
///
|
||||
/// [isFile]/[readFile]/[isDir] are injectable for tests; defaults touch the
|
||||
/// real fs.
|
||||
String presetRootFor(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile, bool Function(String path)? isDir}) {
|
||||
final root = _stripTrailingSep(workspaceRoot);
|
||||
final probe = isFile ?? _isFile;
|
||||
final read = readFile ?? _readFile;
|
||||
final dirProbe = isDir ?? _isDir;
|
||||
final gitPointer = '$root/.git';
|
||||
if (!probe(gitPointer)) return root;
|
||||
final content = read(gitPointer);
|
||||
if (content == null) return root;
|
||||
final match = RegExp(r'^gitdir:\s*(.+?)\s*$', multiLine: true).firstMatch(content);
|
||||
if (match == null) return root;
|
||||
final gitdir = match.group(1)!.replaceAll(r'\', '/');
|
||||
final resolved = _normalize(_isAbsolute(gitdir) ? gitdir : '${root.replaceAll(r'\', '/')}/$gitdir');
|
||||
const marker = '/.git/worktrees/';
|
||||
final idx = resolved.indexOf(marker);
|
||||
if (idx <= 0) return root;
|
||||
final mainRoot = resolved.substring(0, idx);
|
||||
if (!dirProbe('$mainRoot/.git')) return root;
|
||||
return mainRoot;
|
||||
}
|
||||
|
||||
/// The root to key a spawn-time preset lookup on: [workspaceRoot] when [cwd]
|
||||
/// is the workspace root or anywhere below it (a pane spawned in a subdir
|
||||
/// must share the workspace's preset), else [cwd] itself (a spawn in an
|
||||
/// unrelated directory keys off that directory's own repo).
|
||||
String presetLookupRoot(String? cwd, String workspaceRoot) {
|
||||
final ws = _stripTrailingSep(workspaceRoot);
|
||||
if (cwd == null || cwd.isEmpty) return ws;
|
||||
final c = _stripTrailingSep(cwd);
|
||||
if (c == ws || c.startsWith('$ws/')) return ws;
|
||||
return c;
|
||||
}
|
||||
|
||||
/// Read [workspaceRoot]'s preset through an injected settings [read] (key →
|
||||
/// stored value). Tolerant of a malformed value: anything that isn't a list of
|
||||
/// non-empty strings is skipped, mirroring `AccountRegistry.accounts`.
|
||||
List<String> presetDirsFrom(
|
||||
Object? Function(String key) read,
|
||||
String workspaceRoot, {
|
||||
bool Function(String path)? isFile,
|
||||
String? Function(String path)? readFile,
|
||||
bool Function(String path)? isDir,
|
||||
}) {
|
||||
final raw = read(pathPresetKey(workspaceRoot, isFile: isFile, readFile: readFile, isDir: isDir));
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final e in raw)
|
||||
if (e is String && e.trim().isNotEmpty) e,
|
||||
];
|
||||
}
|
||||
|
||||
/// Pure prepend: [preset] dirs (de-duplicated, order kept) ahead of [base],
|
||||
/// with base entries that repeat a preset dir dropped so the preset always
|
||||
/// wins. An empty preset returns [base] unchanged.
|
||||
///
|
||||
/// Contract: one directory per entry. An entry containing [sep] is malformed
|
||||
/// (the CLI/UI reject it at input time; this guards stored values that
|
||||
/// predate the check) and is skipped — joined verbatim it would smuggle
|
||||
/// extra tokens into PATH, and a trailing separator yields an EMPTY token,
|
||||
/// which POSIX shells resolve as CWD.
|
||||
String applyPathPreset(String base, List<String> preset, {String sep = ':'}) {
|
||||
final dirs = <String>[];
|
||||
for (final d in preset) {
|
||||
final t = d.trim();
|
||||
if (t.isNotEmpty && !t.contains(sep) && !dirs.contains(t)) dirs.add(t);
|
||||
}
|
||||
if (dirs.isEmpty) return base;
|
||||
final baseParts = base.isEmpty ? const <String>[] : base.split(sep);
|
||||
return [...dirs, ...baseParts.where((p) => !dirs.contains(p))].join(sep);
|
||||
}
|
||||
|
||||
/// Capture-from-login-shell diff (D-106's `env path capture`): the entries the
|
||||
/// login-shell PATH has that the process PATH lacks — the dirs a desktop
|
||||
/// launch dropped, i.e. the preset candidates. Empty when the probe failed
|
||||
/// ([loginPath] null/empty). Order-preserved, de-duplicated.
|
||||
List<String> missingLoginShellDirs({required String? loginPath, required String processPath, String sep = ':'}) {
|
||||
if (loginPath == null || loginPath.isEmpty) return const [];
|
||||
final have = processPath.split(sep).toSet();
|
||||
final out = <String>[];
|
||||
for (final d in loginPath.split(sep)) {
|
||||
if (d.isNotEmpty && !have.contains(d) && !out.contains(d)) out.add(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool _isFile(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.file;
|
||||
|
||||
bool _isDir(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.directory;
|
||||
|
||||
String? _readFile(String path) {
|
||||
try {
|
||||
return File(path).readAsStringSync();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAbsolute(String p) => p.startsWith('/') || RegExp(r'^[A-Za-z]:/').hasMatch(p);
|
||||
|
||||
/// Segment-wise `.`/`..` normalization over forward-slash paths (a gitdir
|
||||
/// pointer is often relative, e.g. `../../.git/worktrees/x`). No fs access.
|
||||
String _normalize(String p) {
|
||||
final drive = RegExp(r'^[A-Za-z]:').firstMatch(p)?.group(0) ?? '';
|
||||
final rest = p.substring(drive.length);
|
||||
final out = <String>[];
|
||||
for (final seg in rest.split('/')) {
|
||||
if (seg.isEmpty || seg == '.') continue;
|
||||
if (seg == '..') {
|
||||
if (out.isNotEmpty) out.removeLast();
|
||||
continue;
|
||||
}
|
||||
out.add(seg);
|
||||
}
|
||||
return '$drive/${out.join('/')}';
|
||||
}
|
||||
|
||||
String _stripTrailingSep(String p) {
|
||||
var s = p;
|
||||
while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
Vendored
+5
@@ -80,6 +80,11 @@ String expandToolPath(String base, {required bool isMac, required bool isLinux,
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
/// The raw login-shell PATH the probe captured, or null when it is
|
||||
/// unavailable (probe failed / not yet primed / Windows). `env path capture`
|
||||
/// (D-106) diffs this against the process PATH to suggest preset entries.
|
||||
String? loginShellPathOrNull() => _loginShellPath;
|
||||
|
||||
/// Test seam: force the cached login-shell PATH (and mark primed).
|
||||
void debugSetLoginShellPath(String? value) {
|
||||
_loginShellPath = value;
|
||||
|
||||
@@ -162,5 +162,7 @@ class IgnoreSet {
|
||||
/// ignore files. Matches D-004's "walker magic: none except
|
||||
/// `.git/`" — but the tree-view UI benefits from hiding `.pql/` and
|
||||
/// `.dart_tool/` too since users never edit those by hand.
|
||||
static IgnoreSet builtin() => IgnoreSet.parse(const ['.git/\n.pql/\n.clide/\n.dart_tool/\nbuild/\nnode_modules/\n']);
|
||||
/// `.worktrees/` holds in-repo linked worktrees (D-106) — sibling
|
||||
/// checkouts a user opens as their own workspaces, not tree content.
|
||||
static IgnoreSet builtin() => IgnoreSet.parse(const ['.git/\n.pql/\n.clide/\n.dart_tool/\n.worktrees/\nbuild/\nnode_modules/\n']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Client-side filtering of a loaded [VaultGraph] (T-323): narrow to the notes
|
||||
/// near the active one (depth-from-active), and/or by tag include/exclude.
|
||||
///
|
||||
/// Pure — the controller feeds in the already-queried tag map, so this runs
|
||||
/// under `dart test`. The file glob is NOT here: a different glob is a
|
||||
/// different file set, so it re-queries pql; these three refine what's already
|
||||
/// loaded.
|
||||
library;
|
||||
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
|
||||
class GraphFilter {
|
||||
const GraphFilter({this.depth, this.includeTags = const {}, this.excludeTags = const {}});
|
||||
|
||||
/// Hops from the active note to keep; null = the whole graph (no depth limit).
|
||||
final int? depth;
|
||||
|
||||
/// Keep only notes tagged with at least one of these (empty = no filter).
|
||||
final Set<String> includeTags;
|
||||
|
||||
/// Drop notes tagged with any of these.
|
||||
final Set<String> excludeTags;
|
||||
|
||||
bool get isEmpty => depth == null && includeTags.isEmpty && excludeTags.isEmpty;
|
||||
|
||||
GraphFilter copyWith({int? depth, bool clearDepth = false, Set<String>? includeTags, Set<String>? excludeTags}) =>
|
||||
GraphFilter(depth: clearDepth ? null : (depth ?? this.depth), includeTags: includeTags ?? this.includeTags, excludeTags: excludeTags ?? this.excludeTags);
|
||||
|
||||
/// Apply this filter to [full], using [tagsByPath] for the tag predicates and
|
||||
/// [activePath] as the depth root. A depth filter with no active path (or one
|
||||
/// that isn't a node) yields an empty graph — there's no local graph to show.
|
||||
VaultGraph apply(VaultGraph full, {required Map<String, Set<String>> tagsByPath, String? activePath}) {
|
||||
if (isEmpty) return full;
|
||||
Set<String> tagsOf(String id) => tagsByPath[id] ?? const {};
|
||||
var keep = {for (final n in full.nodes) n.id};
|
||||
if (includeTags.isNotEmpty) {
|
||||
keep = keep.where((id) => tagsOf(id).any(includeTags.contains)).toSet();
|
||||
}
|
||||
if (excludeTags.isNotEmpty) {
|
||||
keep = keep.where((id) => !tagsOf(id).any(excludeTags.contains)).toSet();
|
||||
}
|
||||
if (depth != null) {
|
||||
keep = activePath == null ? <String>{} : keep.intersection(full.nodesWithin(activePath, depth!));
|
||||
}
|
||||
return full.subgraph(keep);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -19,10 +19,18 @@ import 'event_sink.dart';
|
||||
import 'pane.dart';
|
||||
|
||||
class PaneRegistry {
|
||||
PaneRegistry({required this.events, this.ptyLog = PtyLog.none});
|
||||
PaneRegistry({required this.events, this.ptyLog = PtyLog.none, String Function(String? cwd)? pathForSpawn})
|
||||
: _pathForSpawn = pathForSpawn ?? _defaultPathForSpawn;
|
||||
|
||||
final DaemonEventSink events;
|
||||
|
||||
/// PATH for a child spawned in `cwd` — the seam the per-workspace PATH
|
||||
/// preset (D-106) is applied through; the default is the plain resolved
|
||||
/// login-shell PATH (T-439). main.dart wires the preset-aware closure.
|
||||
final String Function(String? cwd) _pathForSpawn;
|
||||
|
||||
static String _defaultPathForSpawn(String? _) => resolvedToolPath();
|
||||
|
||||
/// Breadcrumb hook handed to every PTY this registry spawns (T-434). Default
|
||||
/// no-op; production wires it to the kernel Logger + a crumb file.
|
||||
final PtyLog ptyLog;
|
||||
@@ -55,8 +63,9 @@ class PaneRegistry {
|
||||
final fullEnv = <String, String>{
|
||||
...Platform.environment,
|
||||
// The login-shell-resolved PATH so PTY children find user-installed tools
|
||||
// even on a desktop launch (T-439); an explicit caller PATH still wins.
|
||||
'PATH': resolvedToolPath(),
|
||||
// even on a desktop launch (T-439), plus the workspace's PATH preset
|
||||
// (D-106) via [_pathForSpawn]; an explicit caller PATH still wins.
|
||||
'PATH': _pathForSpawn(cwd),
|
||||
'TERM': 'xterm-256color',
|
||||
'COLORTERM': 'truecolor',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
|
||||
+21
-2
@@ -20,9 +20,12 @@ import 'package:flutter/foundation.dart' show compute;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'builtin/canvas/canvas.dart';
|
||||
import 'builtin/diff/diff.dart';
|
||||
import 'builtin/files/files.dart';
|
||||
import 'builtin/git/git.dart';
|
||||
import 'builtin/graph/graph.dart';
|
||||
import 'builtin/pql/pql.dart';
|
||||
import 'builtin/terminal/terminal.dart';
|
||||
import 'extension/extension.dart' show ClideExtension;
|
||||
import 'kernel/kernel.dart';
|
||||
@@ -279,7 +282,17 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
toolchain: tc,
|
||||
);
|
||||
|
||||
final extensions = <ClideExtension>[DiffExtension(), FilesExtension(), GitExtension(), TerminalExtension()];
|
||||
// PqlExtension precedes GraphExtension: the graph view depends on it
|
||||
// (activateAll resolves the order, but this documents the dependency).
|
||||
final extensions = <ClideExtension>[
|
||||
DiffExtension(),
|
||||
FilesExtension(),
|
||||
GitExtension(),
|
||||
TerminalExtension(),
|
||||
PqlExtension(),
|
||||
GraphExtension(),
|
||||
CanvasExtension(),
|
||||
];
|
||||
|
||||
for (final ext in extensions) {
|
||||
try {
|
||||
@@ -368,7 +381,13 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS? (T-438: the
|
||||
// FFI body lives in fd_check_io.dart so the web build can stub it out.)
|
||||
await _testAsync('fd inheritance check', fdInheritanceCheck);
|
||||
// The probe needs the hand-built /tmp/checkfd helper; on machines without
|
||||
// it, skip explicitly rather than failing the whole harness.
|
||||
if (File(fdCheckHelperPath).existsSync()) {
|
||||
await _testAsync('fd inheritance check', fdInheritanceCheck);
|
||||
} else {
|
||||
_say('skip | fd inheritance check | $fdCheckHelperPath not present (hand-built probe helper — see fd_check_io.dart)');
|
||||
}
|
||||
|
||||
_say('');
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ description: >-
|
||||
subsystem handlers (pane, files, editor, git, pql), and the
|
||||
extension framework.
|
||||
publish_to: none
|
||||
version: 2.9.0
|
||||
version: 2.10.0
|
||||
repository: https://github.com/postmeridiem/clide
|
||||
# Short user-facing tagline (the welcome subtitle, web meta
|
||||
# description, etc.). Baked into lib/src/build_info.g.dart by
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/// CanvasExtension (T-322) contributes the canvas pane into the workspace
|
||||
/// slot and routes `selection` messages (openWorkspaceFile,
|
||||
/// `clide ui open canvas [path]`) into app-scoped document tabs — the
|
||||
/// diff/T-233 pattern.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
/// Drain the broadcast-stream microtask hop between a bus publish/emit and
|
||||
/// the extension's listener. Plain `test` body — no fake-async zone, so a
|
||||
/// zero-duration timer fires normally.
|
||||
Future<void> deliver() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
test('declares one workspace tab with the canvas.view identity', () {
|
||||
final tabs = CanvasExtension().contributions.whereType<TabContribution>().toList();
|
||||
expect(tabs, hasLength(1));
|
||||
final t = tabs.single;
|
||||
expect(t.id, 'canvas.view');
|
||||
expect(t.slot, Slots.workspace);
|
||||
expect(t.title, 'Canvas');
|
||||
expect(t.titleKey, 'tab.title');
|
||||
expect(t.i18nNamespace, 'builtin.canvas');
|
||||
});
|
||||
|
||||
test('registers into the workspace slot once activated', () async {
|
||||
f.services.extensions.register(CanvasExtension());
|
||||
await f.services.extensions.activate('builtin.canvas');
|
||||
expect(f.services.panels.tabsFor(Slots.workspace).any((t) => t.id == 'canvas.view'), isTrue);
|
||||
});
|
||||
|
||||
test('a selection message opens a document tab and reveals the pane', () async {
|
||||
final ext = CanvasExtension();
|
||||
f.services.extensions.register(ext);
|
||||
await f.services.extensions.activate('builtin.canvas');
|
||||
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'notes/board.canvas'});
|
||||
await deliver();
|
||||
|
||||
expect(ext.openPaths, ['notes/board.canvas']);
|
||||
expect(f.services.panels.activeTabIn(Slots.workspace), 'canvas.view');
|
||||
});
|
||||
|
||||
test('re-selecting an open document focuses it instead of duplicating', () async {
|
||||
final ext = CanvasExtension();
|
||||
f.services.extensions.register(ext);
|
||||
await f.services.extensions.activate('builtin.canvas');
|
||||
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'b.canvas'});
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
|
||||
await deliver();
|
||||
|
||||
expect(ext.openPaths, ['a.canvas', 'b.canvas']);
|
||||
});
|
||||
|
||||
test('a malformed selection payload is ignored', () async {
|
||||
final ext = CanvasExtension();
|
||||
f.services.extensions.register(ext);
|
||||
await f.services.extensions.activate('builtin.canvas');
|
||||
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': ''});
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'nope': 1});
|
||||
await deliver();
|
||||
|
||||
expect(ext.openPaths, isEmpty);
|
||||
});
|
||||
|
||||
test('an in-place workspace switch drops the open documents (T-269)', () async {
|
||||
final ext = CanvasExtension();
|
||||
f.services.extensions.register(ext);
|
||||
await f.services.extensions.activate('builtin.canvas');
|
||||
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo/a'));
|
||||
await deliver();
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
|
||||
await deliver();
|
||||
expect(ext.openPaths, ['a.canvas']);
|
||||
|
||||
// Same root again — documents stay.
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo/a'));
|
||||
await deliver();
|
||||
expect(ext.openPaths, ['a.canvas']);
|
||||
|
||||
// Different root — documents dropped.
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo/b'));
|
||||
await deliver();
|
||||
expect(ext.openPaths, isEmpty);
|
||||
});
|
||||
|
||||
test('deactivate cancels the subscription and disposes the tabs', () async {
|
||||
final ext = CanvasExtension();
|
||||
f.services.extensions.register(ext);
|
||||
await f.services.extensions.activate('builtin.canvas');
|
||||
await ext.deactivate();
|
||||
|
||||
f.services.messages.publish('builtin.canvas', 'selection', {'path': 'a.canvas'});
|
||||
await deliver();
|
||||
expect(ext.openPaths, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/canvas/json_canvas.dart';
|
||||
import 'package:clide/widgets/src/clide_settings.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
CanvasDoc sampleDoc() => const CanvasDoc(
|
||||
nodes: [
|
||||
TextNode(id: 't', x: 0, y: 0, width: 200, height: 100, text: 'hello', color: '4'),
|
||||
FileNode(id: 'f', x: 300, y: 0, width: 200, height: 100, file: 'notes/a.md'),
|
||||
],
|
||||
edges: [CanvasEdge(id: 'e', fromNode: 't', toNode: 'f', fromSide: CanvasSide.right, toSide: CanvasSide.left)],
|
||||
);
|
||||
|
||||
Future<SurfaceTokens> tokensFrom(WidgetTester tester) async {
|
||||
late SurfaceTokens tokens;
|
||||
await tester.pumpWidget(
|
||||
anchoredHarness(
|
||||
f,
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
tokens = ClideSettings.theme.of(ctx).surface;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<bool> hasInk(CanvasPainter painter, [double s = 200]) async {
|
||||
final rec = ui.PictureRecorder();
|
||||
painter.paint(ui.Canvas(rec, Rect.fromLTWH(0, 0, s, s)), Size(s, s));
|
||||
final img = await rec.endRecording().toImage(s.round(), s.round());
|
||||
final data = (await img.toByteData())!;
|
||||
for (var i = 3; i < data.lengthInBytes; i += 4) {
|
||||
if (data.getUint8(i) != 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
group('canvasContentColor', () {
|
||||
test('maps presets, hex (long + short), and null/unknown', () {
|
||||
expect(canvasContentColor('4'), const Color(0xFF44CF6E));
|
||||
expect(canvasContentColor('#ff0000'), const Color(0xFFFF0000));
|
||||
expect(canvasContentColor('#f00'), const Color(0xFFFF0000));
|
||||
expect(canvasContentColor(null), isNull);
|
||||
expect(canvasContentColor('nonsense'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('CanvasBounds', () {
|
||||
test('spans every node rect', () {
|
||||
const doc = CanvasDoc(
|
||||
nodes: [
|
||||
TextNode(id: 'a', x: -10, y: 5, width: 20, height: 20, text: ''),
|
||||
TextNode(id: 'b', x: 100, y: 0, width: 50, height: 80, text: ''),
|
||||
],
|
||||
);
|
||||
final b = CanvasBounds.of(doc);
|
||||
expect((b.left, b.top, b.right, b.bottom), (-10.0, 0.0, 150.0, 80.0));
|
||||
});
|
||||
});
|
||||
|
||||
group('CanvasViewport.fit', () {
|
||||
test('centres content in the padded pane', () {
|
||||
const doc = CanvasDoc(
|
||||
nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')],
|
||||
);
|
||||
final vp = CanvasViewport.fit(const Size(400, 400), CanvasBounds.of(doc));
|
||||
final r = vp.rectOf(doc.nodes.first);
|
||||
expect(r.center.dx, closeTo(200, 0.001));
|
||||
expect(r.center.dy, closeTo(200, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('paints nodes, edges, and a group', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: sampleDoc(), tokens: tokens))), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('an empty canvas paints nothing (no throw)', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: const CanvasDoc(), tokens: tokens))), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a selected node still paints (draws the focus ring)', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: sampleDoc(), tokens: tokens, selected: 't'))), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('repaints on zoom / pan / selection change, not when identical', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
final doc = sampleDoc();
|
||||
CanvasPainter p({double zoom = 1, Offset pan = Offset.zero, String? selected}) =>
|
||||
CanvasPainter(doc: doc, tokens: tokens, zoom: zoom, pan: pan, selected: selected);
|
||||
expect(p().shouldRepaint(p()), isFalse);
|
||||
expect(p(zoom: 2).shouldRepaint(p()), isTrue);
|
||||
expect(p(pan: const Offset(1, 0)).shouldRepaint(p()), isTrue);
|
||||
expect(p(selected: 't').shouldRepaint(p()), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/// CanvasPaneHost (T-322): renders the extension-owned document tabs —
|
||||
/// empty hint without documents, per-tab load through `files.read`, parse
|
||||
/// errors surfaced as muted text, valid documents as an interactive
|
||||
/// [CanvasView].
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
import 'package:clide/builtin/canvas/src/canvas_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
const _validCanvas = '{"nodes":[{"id":"n1","type":"text","text":"hi","x":0,"y":0,"width":100,"height":50}],"edges":[]}';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
// The shared harness's Overlay hands unbounded constraints; the pane is a
|
||||
// Column with an Expanded body, so give it a tight box.
|
||||
Widget host(MultitabController<String>? tabs) {
|
||||
return harness(f, SizedBox(width: 800, height: 600, child: CanvasPaneHost(tabs: tabs)));
|
||||
}
|
||||
|
||||
testWidgets('shows the empty hint before activation (null controller)', (tester) async {
|
||||
await tester.pumpWidget(host(null));
|
||||
expect(find.text('Open a .canvas file to view it here.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows the empty hint when no document is open', (tester) async {
|
||||
final tabs = MultitabController<String>();
|
||||
addTearDown(tabs.dispose);
|
||||
await tester.pumpWidget(host(tabs));
|
||||
expect(find.text('Open a .canvas file to view it here.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('loads a document through files.read and renders a CanvasView', (tester) async {
|
||||
final reads = <Object?>[];
|
||||
f.ipc.stub('files.read', (args) async {
|
||||
reads.add(args['path']);
|
||||
return IpcResponse.ok(id: '1', data: {'content': _validCanvas});
|
||||
});
|
||||
final tabs = MultitabController<String>(
|
||||
initial: [const MultitabEntry(id: 'a.canvas', title: 'a.canvas', payload: 'a.canvas')],
|
||||
);
|
||||
addTearDown(tabs.dispose);
|
||||
|
||||
await tester.pumpWidget(host(tabs));
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(reads, ['a.canvas']);
|
||||
expect(find.byType(CanvasView), findsOneWidget);
|
||||
expect(find.text('a.canvas'), findsOneWidget); // the sub-tab label
|
||||
});
|
||||
|
||||
testWidgets('a failed read surfaces the IPC error as muted text', (tester) async {
|
||||
f.ipc.stub('files.read', (args) async {
|
||||
return IpcResponse.err(
|
||||
id: '1',
|
||||
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no such file: gone.canvas'),
|
||||
);
|
||||
});
|
||||
final tabs = MultitabController<String>(
|
||||
initial: [const MultitabEntry(id: 'gone.canvas', title: 'gone.canvas', payload: 'gone.canvas')],
|
||||
);
|
||||
addTearDown(tabs.dispose);
|
||||
|
||||
await tester.pumpWidget(host(tabs));
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('no such file: gone.canvas'), findsOneWidget);
|
||||
expect(find.byType(CanvasView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a non-object top level surfaces the parse error', (tester) async {
|
||||
f.ipc.stub('files.read', (args) async => IpcResponse.ok(id: '1', data: {'content': '[1,2,3]'}));
|
||||
final tabs = MultitabController<String>(
|
||||
initial: [const MultitabEntry(id: 'bad.canvas', title: 'bad.canvas', payload: 'bad.canvas')],
|
||||
);
|
||||
addTearDown(tabs.dispose);
|
||||
|
||||
await tester.pumpWidget(host(tabs));
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('canvas: top level must be a JSON object'), findsOneWidget);
|
||||
expect(find.byType(CanvasView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('two documents render as two sub-tabs, keep-alive across switches', (tester) async {
|
||||
f.ipc.stub('files.read', (args) async => IpcResponse.ok(id: '1', data: {'content': _validCanvas}));
|
||||
final tabs = MultitabController<String>(
|
||||
initial: [
|
||||
const MultitabEntry(id: 'notes/a.canvas', title: 'a.canvas', payload: 'notes/a.canvas'),
|
||||
const MultitabEntry(id: 'notes/b.canvas', title: 'b.canvas', payload: 'notes/b.canvas'),
|
||||
],
|
||||
);
|
||||
addTearDown(tabs.dispose);
|
||||
|
||||
await tester.pumpWidget(host(tabs));
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('a.canvas'), findsOneWidget);
|
||||
expect(find.text('b.canvas'), findsOneWidget);
|
||||
// keepAlive keeps both bodies mounted — the inactive one offstage in
|
||||
// the IndexedStack — so both must be found with skipOffstage off.
|
||||
expect(find.byType(CanvasView, skipOffstage: false), findsNWidgets(2));
|
||||
expect(find.byType(CanvasView), findsOneWidget);
|
||||
|
||||
tabs.activate('notes/b.canvas');
|
||||
await pumpAsync(tester);
|
||||
expect(find.byType(CanvasView, skipOffstage: false), findsNWidgets(2));
|
||||
expect(find.byType(CanvasView), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
|
||||
import 'package:clide/builtin/canvas/src/canvas_view.dart';
|
||||
import 'package:clide/src/canvas/json_canvas.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Widget view(CanvasDoc doc, {void Function(String?)? onSelect, double side = 400}) => anchoredHarness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: side,
|
||||
height: side,
|
||||
child: CanvasView(doc: doc, onSelect: onSelect),
|
||||
),
|
||||
);
|
||||
|
||||
group('hitTestCanvasNode', () {
|
||||
test('finds the card under the point; cards beat the group behind them', () {
|
||||
const doc = CanvasDoc(
|
||||
nodes: [
|
||||
GroupNode(id: 'g', x: 0, y: 0, width: 400, height: 400),
|
||||
TextNode(id: 't', x: 150, y: 150, width: 100, height: 100, text: 'hi'),
|
||||
],
|
||||
);
|
||||
// 400×400 doc into an 800×600 canvas, scale 1.5, centred. The text node's
|
||||
// centre (200,200 in canvas coords) → the card wins over the group.
|
||||
const size = Size(800, 600);
|
||||
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc));
|
||||
final centre = vp.rectOf(doc.nodes[1]).center;
|
||||
expect(hitTestCanvasNode(doc, centre, size), 't');
|
||||
// A point inside the group but outside the card hits the group.
|
||||
final groupOnly = vp.rectOf(doc.nodes[0]).topLeft + const Offset(4, 4);
|
||||
expect(hitTestCanvasNode(doc, groupOnly, size), 'g');
|
||||
});
|
||||
|
||||
test('returns null off any node and on an empty doc', () {
|
||||
const doc = CanvasDoc(
|
||||
nodes: [TextNode(id: 't', x: 0, y: 0, width: 10, height: 10, text: '')],
|
||||
);
|
||||
expect(hitTestCanvasNode(doc, const Offset(-999, -999), const Size(400, 400)), isNull);
|
||||
expect(hitTestCanvasNode(const CanvasDoc(), Offset.zero, const Size(400, 400)), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('tapping a node selects it, tapping empty space clears it', (tester) async {
|
||||
String? picked = '__unset__';
|
||||
// One node fills the padded canvas, so its centre is the view centre.
|
||||
const doc = CanvasDoc(
|
||||
nodes: [TextNode(id: 'only', x: 0, y: 0, width: 100, height: 100, text: 'hi')],
|
||||
);
|
||||
await tester.pumpWidget(view(doc, onSelect: (id) => picked = id));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(CanvasView)); // centre → the node
|
||||
expect(picked, 'only');
|
||||
|
||||
await tester.tapAt(tester.getTopLeft(find.byType(CanvasView)) + const Offset(3, 3)); // padding → miss
|
||||
expect(picked, isNull);
|
||||
});
|
||||
|
||||
testWidgets('dragging pans; a reverse drag restores the hit location', (tester) async {
|
||||
String? picked = '__unset__';
|
||||
// Two small, spread-out nodes so each renders small enough that a pan moves
|
||||
// it clear of its old screen spot (a single node would fill the pane).
|
||||
const doc = CanvasDoc(
|
||||
nodes: [
|
||||
TextNode(id: 'a', x: 0, y: 0, width: 60, height: 60, text: 'a'),
|
||||
TextNode(id: 'b', x: 400, y: 400, width: 60, height: 60, text: 'b'),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(view(doc, onSelect: (id) => picked = id));
|
||||
await tester.pump();
|
||||
final origin = tester.getTopLeft(find.byType(CanvasView));
|
||||
final size = tester.getSize(find.byType(CanvasView));
|
||||
final aSpot = origin + CanvasViewport.fit(size, CanvasBounds.of(doc)).rectOf(doc.nodes[0]).center;
|
||||
|
||||
await tester.tapAt(aSpot);
|
||||
expect(picked, 'a'); // baseline hit
|
||||
|
||||
await tester.drag(find.byType(CanvasView), const Offset(140, 0));
|
||||
await tester.pump();
|
||||
await tester.tapAt(aSpot);
|
||||
expect(picked, isNull); // node a panned away from its old spot
|
||||
|
||||
await tester.drag(find.byType(CanvasView), const Offset(-140, 0)); // equal, opposite → net zero
|
||||
await tester.pump();
|
||||
await tester.tapAt(aSpot);
|
||||
expect(picked, 'a'); // panned back
|
||||
});
|
||||
|
||||
testWidgets('a scroll signal zooms without throwing', (tester) async {
|
||||
const doc = CanvasDoc(
|
||||
nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')],
|
||||
);
|
||||
await tester.pumpWidget(view(doc));
|
||||
await tester.pump();
|
||||
final centre = tester.getCenter(find.byType(CanvasView));
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(centre));
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120)));
|
||||
await tester.pump();
|
||||
expect(find.byType(CanvasView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an empty canvas takes a tap without throwing', (tester) async {
|
||||
await tester.pumpWidget(view(const CanvasDoc()));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byType(CanvasView));
|
||||
expect(find.byType(CanvasView), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -67,6 +67,33 @@ void main() {
|
||||
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: null, clideCliDir: '/opt/clide/bin');
|
||||
expect(d['PATH'], '/opt/clide/bin');
|
||||
});
|
||||
|
||||
test('exports PATH for a preset even when clide is already resolvable (D-106)', () {
|
||||
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: null, prependDirs: ['/opt/go/bin']);
|
||||
expect(d['PATH'], '/opt/go/bin:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('preset dirs come first, then the cli dir, then the current PATH (D-106)', () {
|
||||
final d = agentEnvDelta(
|
||||
workspaceRoot: '/repo',
|
||||
socketPath: '/s.sock',
|
||||
currentPath: '/usr/bin',
|
||||
clideCliDir: '/home/dev/.local/bin',
|
||||
prependDirs: ['/opt/go/bin', '/brew/bin'],
|
||||
);
|
||||
expect(d['PATH'], '/opt/go/bin:/brew/bin:/home/dev/.local/bin:/usr/bin');
|
||||
});
|
||||
|
||||
test('a preset dir already on the current PATH is not duplicated (D-106)', () {
|
||||
final d = agentEnvDelta(
|
||||
workspaceRoot: '/repo',
|
||||
socketPath: '/s.sock',
|
||||
currentPath: '/opt/go/bin:/usr/bin',
|
||||
clideCliDir: null,
|
||||
prependDirs: ['/opt/go/bin'],
|
||||
);
|
||||
expect(d['PATH'], '/opt/go/bin:/usr/bin');
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveClideCliDir (T-215)', () {
|
||||
@@ -143,5 +170,16 @@ void main() {
|
||||
final b = agentBootstrap('/ws', base: {'CLAUDE_CONFIG_DIR': '/override'}, boundConfigDir: (_) => '/bound');
|
||||
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/override');
|
||||
});
|
||||
|
||||
test('the workspace PATH preset lands at the head of the delta PATH (D-106)', () {
|
||||
final b = agentBootstrap('/ws', pathPreset: (cwd) => cwd == '/ws' ? ['/opt/go/bin'] : const []);
|
||||
expect(b.envDelta['PATH'], startsWith('/opt/go/bin:'));
|
||||
});
|
||||
|
||||
test('no preset wired → bootstrap behaves as before (no gratuitous PATH export)', () {
|
||||
final emptyPreset = agentBootstrap('/ws', pathPreset: (_) => const []);
|
||||
final unwired = agentBootstrap('/ws');
|
||||
expect(emptyPreset.envDelta.containsKey('PATH'), unwired.envDelta.containsKey('PATH'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,6 +373,39 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(cycles, 0);
|
||||
});
|
||||
|
||||
testWidgets('Ctrl+Shift+M fires onCycleModeFull, not onCycleMode (T-510)', (tester) async {
|
||||
var safe = 0, full = 0;
|
||||
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => safe++, onCycleModeFull: () => full++)));
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
|
||||
expect(full, 1);
|
||||
expect(safe, 0);
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('plain Ctrl+M still cycles the safe trio only', (tester) async {
|
||||
var safe = 0, full = 0;
|
||||
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => safe++, onCycleModeFull: () => full++)));
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
|
||||
expect(safe, 1);
|
||||
expect(full, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClaudeComposer external focus node (T-227)', () {
|
||||
|
||||
@@ -90,6 +90,20 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('nextPermissionMode (T-510)', () {
|
||||
test('cycles the full list including bypass and wraps', () {
|
||||
expect(nextPermissionMode('default'), 'acceptEdits');
|
||||
expect(nextPermissionMode('acceptEdits'), 'plan');
|
||||
expect(nextPermissionMode('plan'), 'bypassPermissions');
|
||||
expect(nextPermissionMode('bypassPermissions'), 'default');
|
||||
});
|
||||
|
||||
test('unknown restarts at default', () {
|
||||
expect(nextPermissionMode('whatever'), 'default');
|
||||
expect(kFullPermissionCycle, contains('bypassPermissions'));
|
||||
});
|
||||
});
|
||||
|
||||
group('statusSegmentsAroundMode (T-226)', () {
|
||||
test('splits model (leading) from ctx/cost/rate (trailing), mode excluded', () {
|
||||
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000, cost: 0.05);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// Tests for the composer permission-mode control (T-275): opens a menu of the
|
||||
/// safe trio + a disabled bypass row, selecting sets the mode, and it coexists
|
||||
/// with the composer's Stop row while busy.
|
||||
/// safe trio + a shift-click-gated bypass row (T-510), selecting sets the
|
||||
/// mode, and it coexists with the composer's Stop row while busy.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_composer.dart';
|
||||
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -48,7 +49,7 @@ void main() {
|
||||
expect(find.text('accept-edits'), findsNothing, reason: 'menu closes on select');
|
||||
});
|
||||
|
||||
testWidgets('the bypass row is disabled — selecting it does nothing', (tester) async {
|
||||
testWidgets('a plain click on the bypass row does nothing and keeps the menu open', (tester) async {
|
||||
var picked = '';
|
||||
await pump(tester, 'default', (m) => picked = m);
|
||||
await tester.pump();
|
||||
@@ -56,7 +57,24 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('bypass'));
|
||||
await tester.pump();
|
||||
expect(picked, '', reason: 'bypass stays behind the cockpit guard (T-181)');
|
||||
expect(picked, '', reason: 'bypass is never a plain click away (T-510)');
|
||||
expect(find.text('bypass'), findsOneWidget, reason: 'menu stays open for a retry with shift');
|
||||
});
|
||||
|
||||
testWidgets('shift-click on the bypass row selects it and closes the menu (T-510)', (tester) async {
|
||||
var picked = '';
|
||||
await pump(tester, 'default', (m) => picked = m);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byType(PermissionModeControl));
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.tap(find.text('bypass'));
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.pump();
|
||||
|
||||
expect(picked, 'bypassPermissions');
|
||||
expect(find.text('bypass'), findsNothing, reason: 'menu closes after the shift-select');
|
||||
});
|
||||
|
||||
testWidgets('per-mode helpers map labels/colours/icons', (tester) async {
|
||||
|
||||
@@ -53,6 +53,19 @@ void main() {
|
||||
expect(spawnedArgs.single, isNot(contains('--effort')));
|
||||
});
|
||||
|
||||
test('the workspace PATH preset reaches the spawned session env (D-106)', () async {
|
||||
final envs = <Map<String, String>?>[];
|
||||
final preset = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
envs.add(env);
|
||||
return _FakeProc();
|
||||
},
|
||||
pathPresetFor: (cwd) => cwd == '/repo' ? const ['/opt/go/bin'] : const [],
|
||||
);
|
||||
await preset.spawn(SpawnSpec(id: 'p1', role: 'primary', sessionId: 'p1-uuid', cwd: '/repo'));
|
||||
expect(envs.single?['PATH'], startsWith('/opt/go/bin:'), reason: 'preset dirs lead the delta PATH');
|
||||
});
|
||||
|
||||
test('a fresh session gets the skills nudge; resume + fork do not (T-490)', () async {
|
||||
String appendPrompt(List<String> args) {
|
||||
final i = args.indexOf('--append-system-prompt');
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:clide/builtin/graph/src/graph_controller.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/graph/graph_filter.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/fake_ipc.dart';
|
||||
|
||||
void main() {
|
||||
group('GraphController', () {
|
||||
late DaemonBus bus;
|
||||
late FakeDaemonClient ipc;
|
||||
|
||||
setUp(() {
|
||||
bus = DaemonBus();
|
||||
ipc = FakeDaemonClient(log: Logger(), events: bus);
|
||||
});
|
||||
tearDown(() => bus.dispose());
|
||||
|
||||
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
||||
IpcResponse err(String msg) => IpcResponse.err(
|
||||
id: '1',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: msg),
|
||||
);
|
||||
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (per path):
|
||||
/// outlinks from [vault], tags from [tags].
|
||||
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
|
||||
ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
'files': [
|
||||
for (final p in vault.keys) {'path': p},
|
||||
],
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.meta', (args) async {
|
||||
final path = args['path'] as String?;
|
||||
return ok({
|
||||
'outlinks': [
|
||||
for (final t in vault[path] ?? const []) {'target': t},
|
||||
],
|
||||
'tags': tags[path] ?? const <String>[],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('assembles a graph from files + their meta outlinks', () async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
var notified = 0;
|
||||
c.addListener(() => notified++);
|
||||
await c.load();
|
||||
|
||||
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]);
|
||||
expect(c.loading, isFalse);
|
||||
expect(c.error, isNull);
|
||||
expect(notified, greaterThan(0));
|
||||
});
|
||||
|
||||
test('strips #heading fragments so a heading link still connects the notes', () async {
|
||||
stubVault({
|
||||
'a.md': ['b.md#a-heading'],
|
||||
'b.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.edgePairs, [('a.md', 'b.md')]); // fragment stripped → real edge
|
||||
});
|
||||
|
||||
test('collects tags into a sorted availableTags union', () async {
|
||||
stubVault(
|
||||
{'a.md': const [], 'b.md': const []},
|
||||
tags: {
|
||||
'a.md': ['project', 'note'],
|
||||
'b.md': ['note'],
|
||||
},
|
||||
);
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.availableTags, ['note', 'project']);
|
||||
});
|
||||
|
||||
test('a pql.files failure clears the graph and surfaces the error', () async {
|
||||
ipc.stub('pql.files', (_) async => err('index locked'));
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.isEmpty, isTrue);
|
||||
expect(c.error, contains('index locked'));
|
||||
expect(c.loading, isFalse);
|
||||
});
|
||||
|
||||
test('a per-file meta failure drops that file\'s edges, keeps the node', () async {
|
||||
ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
'files': [
|
||||
{'path': 'a.md'},
|
||||
{'path': 'b.md'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
ipc.stub('pql.meta', (args) async {
|
||||
if (args['path'] == 'a.md') return err('boom');
|
||||
return ok({'outlinks': const [], 'tags': const []});
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(c.graph.nodes.map((n) => n.id), containsAll(['a.md', 'b.md']));
|
||||
expect(c.graph.edgePairs, isEmpty);
|
||||
expect(c.error, isNull);
|
||||
});
|
||||
|
||||
test('setGlob reloads with the new glob; the same glob is a no-op', () async {
|
||||
final globs = <String>[];
|
||||
ipc.stub('pql.files', (args) async {
|
||||
globs.add(args['glob'] as String);
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
c.setGlob('notes/**');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
c.setGlob('notes/**'); // no-op
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(globs, ['**/*.md', 'notes/**']);
|
||||
expect(c.glob, 'notes/**');
|
||||
});
|
||||
|
||||
test('setFilter narrows visibleGraph without reloading', () async {
|
||||
var files = 0;
|
||||
ipc.stub('pql.files', (_) async {
|
||||
files++;
|
||||
return ok({
|
||||
'files': [
|
||||
{'path': 'a.md'},
|
||||
{'path': 'b.md'},
|
||||
],
|
||||
});
|
||||
});
|
||||
ipc.stub(
|
||||
'pql.meta',
|
||||
(args) async => ok({
|
||||
'outlinks': const [],
|
||||
'tags': args['path'] == 'a.md' ? ['project'] : const <String>[],
|
||||
}),
|
||||
);
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
expect(files, 1);
|
||||
expect(c.visibleGraph.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
|
||||
c.setFilter(const GraphFilter(includeTags: {'project'}));
|
||||
expect(c.visibleGraph.nodes.map((n) => n.id), ['a.md']);
|
||||
expect(files, 1); // client-side — no reload
|
||||
});
|
||||
|
||||
test('a depth filter re-centres on the active editor file', () async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': ['c.md'],
|
||||
'c.md': [],
|
||||
});
|
||||
final c = GraphController(ipc: ipc, events: bus);
|
||||
await c.load();
|
||||
c.setFilter(const GraphFilter(depth: 1));
|
||||
expect(c.visibleGraph.isEmpty, isTrue); // no active file yet
|
||||
bus.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.active-changed', data: const {'path': 'b.md'}, ts: DateTime.now()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(c.activePath, 'b.md');
|
||||
expect(c.visibleGraph.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md', 'c.md']));
|
||||
});
|
||||
|
||||
test('a files event triggers a debounced reload; non-files events do not', () async {
|
||||
var files = 0;
|
||||
ipc.stub('pql.files', (_) async {
|
||||
files++;
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: Duration.zero);
|
||||
expect(files, 0);
|
||||
|
||||
bus.emit(DaemonEvent(subsystem: 'git', kind: 'git.status', data: const {}, ts: DateTime.now()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(files, 0); // non-files event ignored
|
||||
|
||||
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: const {'path': 'x.md'}, ts: DateTime.now()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(files, 1); // reload fired
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('a burst of files events coalesces into a single reload', () async {
|
||||
var files = 0;
|
||||
ipc.stub('pql.files', (_) async {
|
||||
files++;
|
||||
return ok({'files': const []});
|
||||
});
|
||||
ipc.stub('pql.meta', (_) async => ok({'outlinks': const [], 'tags': const []}));
|
||||
final c = GraphController(ipc: ipc, events: bus, refreshDebounce: const Duration(milliseconds: 20));
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
bus.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': '$i.md'}, ts: DateTime.now()));
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 40));
|
||||
expect(files, 1);
|
||||
c.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// GraphExtension (T-323) contributes the vault-graph tab into the context
|
||||
/// panel — but only once its pql dependency is active (it reads link data
|
||||
/// through pql).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/graph/graph.dart';
|
||||
import 'package:clide/builtin/graph/src/graph_panel.dart';
|
||||
import 'package:clide/builtin/pql/pql.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
test('declares one context-panel tab with the graph.view identity', () {
|
||||
final tabs = GraphExtension().contributions.whereType<TabContribution>().toList();
|
||||
expect(tabs, hasLength(1));
|
||||
final t = tabs.single;
|
||||
expect(t.id, 'graph.view');
|
||||
expect(t.slot, Slots.contextPanel);
|
||||
expect(t.title, 'Graph');
|
||||
expect(t.titleKey, 'tab.graph.title');
|
||||
expect(t.i18nNamespace, 'builtin.graph');
|
||||
});
|
||||
|
||||
testWidgets('the tab builds a GraphPanel', (tester) async {
|
||||
final tab = GraphExtension().contributions.whereType<TabContribution>().single;
|
||||
late Widget built;
|
||||
await tester.pumpWidget(
|
||||
anchoredHarness(
|
||||
f,
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
built = tab.build(ctx); // capture without mounting — no load/ipc needed
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(built, isA<GraphPanel>());
|
||||
});
|
||||
|
||||
test('registers into the context panel once activated (after its pql dep)', () async {
|
||||
f.services.extensions.register(PqlExtension());
|
||||
f.services.extensions.register(GraphExtension());
|
||||
await f.services.extensions.activate('builtin.pql');
|
||||
await f.services.extensions.activate('builtin.graph');
|
||||
expect(f.services.panels.tabsFor(Slots.contextPanel).any((t) => t.id == 'graph.view'), isTrue);
|
||||
});
|
||||
|
||||
test('is skipped when its pql dependency is not active', () async {
|
||||
f.services.extensions.register(GraphExtension());
|
||||
await f.services.extensions.activate('builtin.graph'); // dep missing → skipped
|
||||
expect(f.services.panels.tabsFor(Slots.contextPanel).any((t) => t.id == 'graph.view'), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clide/builtin/graph/src/graph_painter.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/graph/force_layout.dart';
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
import 'package:clide/widgets/src/clide_settings.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<SurfaceTokens> tokensFrom(WidgetTester tester) async {
|
||||
late SurfaceTokens tokens;
|
||||
await tester.pumpWidget(
|
||||
anchoredHarness(
|
||||
f,
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
tokens = ClideSettings.theme.of(ctx).surface;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<bool> hasInk(GraphPainter painter, [double s = 200]) async {
|
||||
final rec = ui.PictureRecorder();
|
||||
painter.paint(ui.Canvas(rec, Rect.fromLTWH(0, 0, s, s)), Size(s, s));
|
||||
final img = await rec.endRecording().toImage(s.round(), s.round());
|
||||
final data = (await img.toByteData())!;
|
||||
for (var i = 3; i < data.lengthInBytes; i += 4) {
|
||||
if (data.getUint8(i) != 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
VaultGraph twoNodes() => VaultGraph.fromOutlinks({
|
||||
'a.md': const ['b.md'],
|
||||
'b.md': const [],
|
||||
});
|
||||
|
||||
testWidgets('paints nodes, edges, and labels', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
final g = twoNodes();
|
||||
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
|
||||
// toImage/toByteData is real engine async — run it off the fake test clock.
|
||||
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens))), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('dims nodes outside the hover neighbourhood but still draws ink', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
final g = twoNodes();
|
||||
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
|
||||
// Highlight only a.md — b.md and the edge dim.
|
||||
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens, highlight: g.neighborhood('a.md')))), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('an empty graph paints nothing (no throw)', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: const VaultGraph([], []), positions: const {}, tokens: tokens))), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('paints under a user zoom + pan', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
final g = twoNodes();
|
||||
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
|
||||
expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens, zoom: 2, pan: const Offset(15, -10)))), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('repaints when zoom or pan changes, not when identical', (tester) async {
|
||||
final tokens = await tokensFrom(tester);
|
||||
final g = twoNodes();
|
||||
final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs);
|
||||
GraphPainter p({double zoom = 1, Offset pan = Offset.zero}) => GraphPainter(graph: g, positions: pos, tokens: tokens, zoom: zoom, pan: pan);
|
||||
expect(p().shouldRepaint(p()), isFalse);
|
||||
expect(p(zoom: 2).shouldRepaint(p()), isTrue);
|
||||
expect(p(pan: const Offset(1, 0)).shouldRepaint(p()), isTrue);
|
||||
});
|
||||
|
||||
group('hitTestNode', () {
|
||||
test('finds the node under the point, null when far or empty', () {
|
||||
final g = VaultGraph.fromOutlinks({'a.md': const []});
|
||||
// A single node lays out at the layout centre (400,300); an 800×600 canvas
|
||||
// over an 800×600 layout is scale 1, no offset → the node sits at (400,300).
|
||||
final pos = ForceLayout.compute(['a.md'], const []);
|
||||
expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600)), 'a.md');
|
||||
expect(hitTestNode(g, pos, const Offset(20, 20), const Size(800, 600)), isNull);
|
||||
expect(hitTestNode(g, const {}, Offset.zero, const Size(800, 600)), isNull);
|
||||
});
|
||||
|
||||
test('pan shifts the hit location; zoom scales about the centre', () {
|
||||
final g = VaultGraph.fromOutlinks({'a.md': const []});
|
||||
final pos = ForceLayout.compute(['a.md'], const []); // single node at (400,300)
|
||||
// A pan moves the node by the same pixels — hit follows it, misses the old spot.
|
||||
expect(hitTestNode(g, pos, const Offset(500, 300), const Size(800, 600), pan: const Offset(100, 0)), 'a.md');
|
||||
expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600), pan: const Offset(100, 0)), isNull);
|
||||
// Zoom scales about the canvas centre, so a centre node stays under it.
|
||||
expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600), zoom: 3), 'a.md');
|
||||
});
|
||||
});
|
||||
|
||||
group('GraphViewport.fit', () {
|
||||
test('a centre point is pan-translated and zoom-invariant', () {
|
||||
const canvas = Size(800, 600), layout = Size(800, 600);
|
||||
final centre = (x: 400.0, y: 300.0);
|
||||
expect(GraphViewport.fit(canvas, layout).toPixel(centre), const Offset(400, 300));
|
||||
expect(GraphViewport.fit(canvas, layout, zoom: 4).toPixel(centre), const Offset(400, 300));
|
||||
expect(GraphViewport.fit(canvas, layout, pan: const Offset(10, 20)).toPixel(centre), const Offset(410, 320));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/graph/src/graph_panel.dart';
|
||||
import 'package:clide/builtin/graph/src/graph_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
IpcResponse ok(Map<String, Object?> data) => IpcResponse.ok(id: '1', data: data);
|
||||
|
||||
/// Stubs `pql.files` (from the map's keys) and `pql.meta` (outlinks + tags).
|
||||
void stubVault(Map<String, List<String>> vault, {Map<String, List<String>> tags = const {}}) {
|
||||
f.ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => ok({
|
||||
'files': [
|
||||
for (final p in vault.keys) {'path': p},
|
||||
],
|
||||
}),
|
||||
);
|
||||
f.ipc.stub('pql.meta', (args) async {
|
||||
final path = args['path'] as String?;
|
||||
return ok({
|
||||
'outlinks': [
|
||||
for (final t in vault[path] ?? const []) {'target': t},
|
||||
],
|
||||
'tags': tags[path] ?? const <String>[],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget panel([Size size = const Size(400, 400)]) => anchoredHarness(f, SizedBox(width: size.width, height: size.height, child: const GraphPanel()));
|
||||
|
||||
testWidgets('shows a spinner (no filter bar) while the first load is in flight', (tester) async {
|
||||
f.ipc.stub('pql.files', (_) => Completer<IpcResponse>().future);
|
||||
await tester.pumpWidget(panel());
|
||||
await tester.pump();
|
||||
expect(find.byType(ClideSpinner), findsOneWidget);
|
||||
expect(find.byType(ClideFilterBox), findsNothing);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders the graph and the filter bar once loaded', (tester) async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
});
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
expect(find.byType(GraphView), findsOneWidget);
|
||||
expect(find.byType(ClideFilterBox), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows an empty message for a vault with no notes', (tester) async {
|
||||
stubVault(const {});
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('No linked notes in this vault.'), findsOneWidget);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('surfaces a load error', (tester) async {
|
||||
f.ipc.stub(
|
||||
'pql.files',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '1',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'index locked'),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('index locked'), findsOneWidget);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('clicking a node opens it in the editor', (tester) async {
|
||||
stubVault({'only.md': const []});
|
||||
String? opened;
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened = args['path'] as String?;
|
||||
return ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
await tester.tap(find.byType(GraphView));
|
||||
await pumpAsync(tester);
|
||||
expect(opened, 'only.md');
|
||||
});
|
||||
|
||||
testWidgets('a depth pill with no active note shows the local-graph hint', (tester) async {
|
||||
stubVault({
|
||||
'a.md': ['b.md'],
|
||||
'b.md': [],
|
||||
});
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
await tester.tap(find.text('1')); // depth-1 from the (absent) active note
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('Open a note to see its local graph.'), findsOneWidget);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a tag pill cycles include → exclude and filters the graph', (tester) async {
|
||||
stubVault(
|
||||
{'only.md': const []},
|
||||
tags: {
|
||||
'only.md': ['x'],
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(panel());
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('x'), findsOneWidget); // neutral tag pill
|
||||
|
||||
await tester.tap(find.text('x')); // → include; the note carries x, so it stays
|
||||
await pumpAsync(tester);
|
||||
expect(find.byType(GraphView), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('+x')); // → exclude; the note carries x, so it drops
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('No notes match the filter.'), findsOneWidget);
|
||||
expect(find.byType(GraphView), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:clide/builtin/graph/src/graph_view.dart';
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('clicking a node opens it', (tester) async {
|
||||
String? opened;
|
||||
final g = VaultGraph.fromOutlinks({'only.md': const []});
|
||||
await tester.pumpWidget(
|
||||
anchoredHarness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: GraphView(graph: g, layoutSize: const Size(300, 300), onOpen: (id) => opened = id),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
// A single node lays out at the centre → the view's centre.
|
||||
await tester.tap(find.byType(GraphView));
|
||||
expect(opened, 'only.md');
|
||||
});
|
||||
|
||||
testWidgets('renders an empty graph without error', (tester) async {
|
||||
await tester.pumpWidget(anchoredHarness(f, const SizedBox(width: 200, height: 200, child: GraphView(graph: VaultGraph([], [])))));
|
||||
await tester.pump();
|
||||
expect(find.byType(GraphView), findsOneWidget);
|
||||
await tester.tap(find.byType(GraphView)); // no node, no onOpen — must not throw
|
||||
});
|
||||
|
||||
testWidgets('dragging pans the graph; a reverse pan restores it', (tester) async {
|
||||
String? opened;
|
||||
final g = VaultGraph.fromOutlinks({'only.md': const []});
|
||||
await tester.pumpWidget(
|
||||
anchoredHarness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: 400,
|
||||
height: 400,
|
||||
child: GraphView(graph: g, onOpen: (id) => opened = id),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final centre = tester.getCenter(find.byType(GraphView));
|
||||
// Pan right — the node leaves the centre, so a centre tap misses. (An equal,
|
||||
// opposite drag cancels the same gesture slop, so the net returns to zero —
|
||||
// robust to the exact slop the arena consumes.)
|
||||
await tester.drag(find.byType(GraphView), const Offset(120, 0));
|
||||
await tester.pump();
|
||||
await tester.tapAt(centre);
|
||||
expect(opened, isNull);
|
||||
await tester.drag(find.byType(GraphView), const Offset(-120, 0));
|
||||
await tester.pump();
|
||||
await tester.tapAt(centre);
|
||||
expect(opened, 'only.md');
|
||||
});
|
||||
|
||||
testWidgets('a scroll signal zooms without throwing', (tester) async {
|
||||
final g = VaultGraph.fromOutlinks({
|
||||
'a.md': const ['b.md'],
|
||||
'b.md': const [],
|
||||
});
|
||||
await tester.pumpWidget(anchoredHarness(f, SizedBox(width: 400, height: 400, child: GraphView(graph: g))));
|
||||
await tester.pump();
|
||||
final centre = tester.getCenter(find.byType(GraphView));
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(centre));
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120))); // zoom in
|
||||
await tester.pump();
|
||||
expect(find.byType(GraphView), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/// Tests for the backlinks panel — [BacklinksController] (active-file
|
||||
/// tracking, pql fetches, event-driven refresh) and [BacklinksView]
|
||||
/// (empty/loading/error states, link groups, row navigation). Previously
|
||||
/// untested; brought under test when the T-511 coverage sweep exposed the
|
||||
/// gap.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/pql/src/backlinks_controller.dart';
|
||||
import 'package:clide/builtin/pql/src/backlinks_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
IpcResponse _err(String m) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: m),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
void stubLinks({Object? backlinks = const [], Object? outlinks = const [], bool fail = false}) {
|
||||
f.ipc.stub('pql.backlinks', (_) async => fail ? _err('vault gone') : _ok({'links': backlinks}));
|
||||
f.ipc.stub('pql.outlinks', (_) async => fail ? _err('vault gone') : _ok({'links': outlinks}));
|
||||
}
|
||||
|
||||
DaemonEvent activeChanged(String path) => DaemonEvent(subsystem: 'editor', kind: 'editor.active-changed', data: {'path': path}, ts: DateTime.now().toUtc());
|
||||
|
||||
group('BacklinksController', () {
|
||||
late BacklinksController c;
|
||||
|
||||
setUp(() => c = BacklinksController(ipc: f.ipc, events: f.services.events));
|
||||
tearDown(() => c.dispose());
|
||||
|
||||
test('loadForPath populates both link lists and toggles loading', () async {
|
||||
stubLinks(
|
||||
backlinks: [
|
||||
{'source': 'notes/in.md'},
|
||||
],
|
||||
outlinks: [
|
||||
{'target': 'notes/out.md', 'alias': 'Out'},
|
||||
],
|
||||
);
|
||||
final loadingSeen = <bool>[];
|
||||
c.addListener(() => loadingSeen.add(c.loading));
|
||||
|
||||
await c.loadForPath('notes/a.md');
|
||||
|
||||
expect(c.activePath, 'notes/a.md');
|
||||
expect(loadingSeen, [true, false]);
|
||||
expect(c.backlinks.single['source'], 'notes/in.md');
|
||||
expect(c.outlinks.single['alias'], 'Out');
|
||||
expect(c.error, isNull);
|
||||
});
|
||||
|
||||
test('both fetches failing surfaces the error; lists stay empty', () async {
|
||||
stubLinks(fail: true);
|
||||
await c.loadForPath('a.md');
|
||||
expect(c.error, 'vault gone');
|
||||
expect(c.backlinks, isEmpty);
|
||||
expect(c.outlinks, isEmpty);
|
||||
});
|
||||
|
||||
test('one side failing degrades to an empty list without an error', () async {
|
||||
f.ipc.stub('pql.backlinks', (_) async => _err('half down'));
|
||||
f.ipc.stub(
|
||||
'pql.outlinks',
|
||||
(_) async => _ok({
|
||||
'links': [
|
||||
{'target': 'b.md'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await c.loadForPath('a.md');
|
||||
expect(c.error, isNull);
|
||||
expect(c.backlinks, isEmpty);
|
||||
expect(c.outlinks, hasLength(1));
|
||||
});
|
||||
|
||||
test('a malformed links payload is tolerated as empty', () async {
|
||||
f.ipc.stub('pql.backlinks', (_) async => _ok(const {'links': 'nonsense'}));
|
||||
f.ipc.stub('pql.outlinks', (_) async => _ok(const {}));
|
||||
await c.loadForPath('a.md');
|
||||
expect(c.backlinks, isEmpty);
|
||||
expect(c.outlinks, isEmpty);
|
||||
});
|
||||
|
||||
test('editor.active-changed refreshes; same path and foreign events do not', () async {
|
||||
var calls = 0;
|
||||
f.ipc.stub('pql.backlinks', (_) async {
|
||||
calls++;
|
||||
return _ok(const {'links': []});
|
||||
});
|
||||
f.ipc.stub('pql.outlinks', (_) async => _ok(const {'links': []}));
|
||||
|
||||
f.services.events.emit(activeChanged('a.md'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls, 1);
|
||||
|
||||
f.services.events.emit(activeChanged('a.md')); // same path — no refetch
|
||||
f.services.events.emit(DaemonEvent(subsystem: 'git', kind: 'changed', data: const {}, ts: DateTime.now().toUtc()));
|
||||
f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.saved', data: const {'path': 'b.md'}, ts: DateTime.now().toUtc()));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls, 1);
|
||||
|
||||
f.services.events.emit(activeChanged('b.md'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls, 2);
|
||||
});
|
||||
|
||||
test('dispose stops listening to the bus', () async {
|
||||
var calls = 0;
|
||||
f.ipc.stub('pql.backlinks', (_) async {
|
||||
calls++;
|
||||
return _ok(const {'links': []});
|
||||
});
|
||||
f.ipc.stub('pql.outlinks', (_) async => _ok(const {'links': []}));
|
||||
c.dispose();
|
||||
f.services.events.emit(activeChanged('a.md'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls, 0);
|
||||
c = BacklinksController(ipc: f.ipc, events: f.services.events); // tearDown disposes a live one
|
||||
});
|
||||
});
|
||||
|
||||
group('BacklinksView', () {
|
||||
Future<void> pump(WidgetTester tester) => tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(width: 320, height: 400, child: BacklinksView()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('no active file → empty-state prompt', (tester) async {
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Open a file'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an active-file change renders the file name, groups, and rows', (tester) async {
|
||||
stubLinks(
|
||||
backlinks: [
|
||||
{'source': 'notes/in.md'},
|
||||
],
|
||||
outlinks: [
|
||||
{'target': 'https://example.com', 'alias': 'Site'},
|
||||
],
|
||||
);
|
||||
await pump(tester);
|
||||
f.services.events.emit(activeChanged('notes/active.md'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('active.md'), findsOneWidget, reason: 'header shows the basename');
|
||||
expect(find.text('Backlinks (1)'), findsOneWidget);
|
||||
expect(find.text('Outlinks (1)'), findsOneWidget);
|
||||
expect(find.text('notes/in.md'), findsOneWidget);
|
||||
expect(find.text('Site'), findsOneWidget, reason: 'alias wins over the raw target');
|
||||
expect(find.text('None'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('empty groups say None; a failed fetch surfaces the error', (tester) async {
|
||||
stubLinks(fail: true);
|
||||
await pump(tester);
|
||||
f.services.events.emit(activeChanged('a.md'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('vault gone'), findsOneWidget);
|
||||
expect(find.text('None'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('tapping a vault link opens it in the editor; an http link does not', (tester) async {
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path']! as String);
|
||||
return _ok(const {});
|
||||
});
|
||||
stubLinks(
|
||||
backlinks: [
|
||||
{'source': 'notes/in.md'},
|
||||
],
|
||||
outlinks: [
|
||||
{'target': 'https://example.com'},
|
||||
],
|
||||
);
|
||||
await pump(tester);
|
||||
f.services.events.emit(activeChanged('a.md'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('notes/in.md'));
|
||||
await tester.pump();
|
||||
expect(opened, ['notes/in.md']);
|
||||
|
||||
await tester.tap(find.text('https://example.com'));
|
||||
await tester.pump();
|
||||
expect(opened, ['notes/in.md'], reason: 'http links never route to the editor');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/// D-106/T-511: the per-workspace PATH preset settings control. Covers the
|
||||
/// no-workspace and empty states, add/remove/reorder against the real
|
||||
/// settings store, worktree key sharing, live updates on a CLI-side write,
|
||||
/// and the capture-from-login-shell suggestion flow.
|
||||
///
|
||||
/// Settings writes are real file I/O, so seeding goes through
|
||||
/// [WidgetTester.runAsync] — awaiting it inside the fake-async body would hang.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/tools_settings/src/path_preset_control.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/env_path_commands.dart' show envPathChannel;
|
||||
import 'package:clide/src/env/path_preset.dart';
|
||||
import 'package:clide/src/env/shell_env.dart' show debugResetLoginShellPath, debugSetLoginShellPath;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
List<String> dirs(String root) => presetDirsFrom((k) => f.services.settings.get<Object>(k), root);
|
||||
|
||||
Future<void> pump(WidgetTester tester) => tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(width: 460, child: PathPresetControl()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('no workspace open → prompts to open one', (tester) async {
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Open a workspace'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('empty preset shows the hint + the add row', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('No preset entries'), findsOneWidget);
|
||||
expect(find.text('Add entry'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('typing a dir + Add persists it, publishes on envPathChannel, and renders the row', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
final published = <Map<String, Object?>>[];
|
||||
final sub = f.services.messages.subscribe(channel: envPathChannel).listen((m) => published.add(m.data));
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), '/opt/go/bin');
|
||||
await tester.tap(find.text('Add entry'));
|
||||
await tester.pump();
|
||||
|
||||
expect(dirs(f.tempDir.path), ['/opt/go/bin']);
|
||||
expect(find.text('/opt/go/bin'), findsOneWidget);
|
||||
expect(find.text('missing'), findsOneWidget, reason: 'the dir does not exist → warning tag');
|
||||
expect(published.single['action'], 'add');
|
||||
expect(published.single['dirs'], ['/opt/go/bin']);
|
||||
});
|
||||
|
||||
testWidgets('a relative entry is rejected with a visible reason and nothing is written', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), 'go/bin');
|
||||
await tester.tap(find.text('Add entry'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('absolute path'), findsOneWidget);
|
||||
expect(dirs(f.tempDir.path), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('an entry containing a PATH separator is rejected (CWD-token guard)', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), '/opt/go/bin:');
|
||||
await tester.tap(find.text('Add entry'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('absolute path'), findsOneWidget);
|
||||
expect(dirs(f.tempDir.path), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('~/ expands against HOME and Enter submits (parity with the CLI verb)', (tester) async {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null || home.isEmpty) return; // no HOME in this environment — the guard path is CLI-tested
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(EditableText), '~/go/bin/');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(dirs(f.tempDir.path), ['$home/go/bin']);
|
||||
});
|
||||
|
||||
testWidgets('an existing dir renders without the missing tag', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await f.services.settings.setProjectDir(f.tempDir);
|
||||
await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), [f.tempDir.path]);
|
||||
});
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
expect(find.text(f.tempDir.path), findsOneWidget);
|
||||
expect(find.text('missing'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('remove and reorder rewrite the stored order', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await f.services.settings.setProjectDir(f.tempDir);
|
||||
await f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/a', '/b', '/c']);
|
||||
});
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Move down: /a'));
|
||||
await tester.pump();
|
||||
expect(dirs(f.tempDir.path), ['/b', '/a', '/c']);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Move up: /c'));
|
||||
await tester.pump();
|
||||
expect(dirs(f.tempDir.path), ['/b', '/c', '/a']);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Remove: /b'));
|
||||
await tester.pump();
|
||||
expect(dirs(f.tempDir.path), ['/c', '/a']);
|
||||
});
|
||||
|
||||
testWidgets('live-updates when the preset changes from outside (CLI-side write)', (tester) async {
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('No preset entries'), findsOneWidget);
|
||||
|
||||
await tester.runAsync(() => f.services.settings.setAt(SettingsScope.app, pathPresetKey(f.tempDir.path), ['/from/cli']));
|
||||
await tester.pump();
|
||||
expect(find.text('/from/cli'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opened from an in-repo worktree: says so and edits the main repo key (D-106)', (tester) async {
|
||||
late Directory repo;
|
||||
late Directory wt;
|
||||
await tester.runAsync(() async {
|
||||
repo = Directory('${f.tempDir.path}/repo')..createSync();
|
||||
Directory('${repo.path}/.git/worktrees/fix').createSync(recursive: true);
|
||||
wt = Directory('${repo.path}/.worktrees/fix')..createSync(recursive: true);
|
||||
File('${wt.path}/.git').writeAsStringSync('gitdir: ${repo.path}/.git/worktrees/fix\n');
|
||||
await f.services.settings.setProjectDir(wt);
|
||||
});
|
||||
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Worktree'), findsOneWidget);
|
||||
expect(find.text(repo.path), findsOneWidget, reason: 'names the shared main repo root');
|
||||
|
||||
await tester.enterText(find.byType(EditableText), '/opt/go/bin');
|
||||
await tester.tap(find.text('Add entry'));
|
||||
await tester.pump();
|
||||
expect(dirs(repo.path), ['/opt/go/bin'], reason: 'worktree writes land on the main repo key');
|
||||
expect(dirs(wt.path), ['/opt/go/bin'], reason: 'reading via the worktree resolves the same key');
|
||||
});
|
||||
|
||||
testWidgets('capture suggests login-shell dirs and a tap adopts one', (tester) async {
|
||||
debugSetLoginShellPath('/cap-a:${Platform.environment['PATH'] ?? ''}');
|
||||
addTearDown(debugResetLoginShellPath);
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Suggest from login shell'));
|
||||
await tester.pump();
|
||||
expect(find.text('/cap-a'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Add suggested entry: /cap-a'));
|
||||
await tester.pump();
|
||||
expect(dirs(f.tempDir.path), ['/cap-a']);
|
||||
expect(find.bySemanticsLabel('Add suggested entry: /cap-a'), findsNothing, reason: 'adopted suggestion leaves the list');
|
||||
});
|
||||
|
||||
testWidgets('capture with no login-shell probe reports nothing to suggest', (tester) async {
|
||||
debugResetLoginShellPath();
|
||||
addTearDown(debugResetLoginShellPath);
|
||||
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
|
||||
await pump(tester);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Suggest from login shell'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Nothing to suggest'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/// Tests for `env path …` (D-106, T-511; D-6 CLI parity). Verifies each verb's
|
||||
/// store effect, the published mutation payloads, the capture diff, and honest
|
||||
/// userErrors — against a fake PathPresetStore so the handler stays
|
||||
/// Flutter-free (runs under `dart test`).
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/env_path_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _FakeStore implements PathPresetStore {
|
||||
final Map<String, List<String>> byCwd = {};
|
||||
|
||||
@override
|
||||
List<String> dirsFor(String cwd) => List.of(byCwd[cwd] ?? const []);
|
||||
|
||||
@override
|
||||
Future<void> setFor(String cwd, List<String> dirs) async {
|
||||
if (dirs.isEmpty) {
|
||||
byCwd.remove(cwd);
|
||||
} else {
|
||||
byCwd[cwd] = List.of(dirs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _FakeStore store;
|
||||
late List<({String publisher, String channel, Map<String, Object?> data})> published;
|
||||
late DaemonDispatcher d;
|
||||
late Set<String> existingDirs;
|
||||
String? loginPath;
|
||||
var processPath = '/usr/bin:/bin';
|
||||
|
||||
void wire({String? cwd = '/repo', bool withStore = true, String? home = '/home/u'}) {
|
||||
store = _FakeStore();
|
||||
published = [];
|
||||
existingDirs = {};
|
||||
loginPath = null;
|
||||
processPath = '/usr/bin:/bin';
|
||||
d = DaemonDispatcher();
|
||||
registerEnvPathCommands(
|
||||
d,
|
||||
() => withStore ? store : null,
|
||||
publisher: () =>
|
||||
(p, c, data) => published.add((publisher: p, channel: c, data: data)),
|
||||
workspaceCwd: () => cwd,
|
||||
home: () => home,
|
||||
dirExists: (dir) => existingDirs.contains(dir),
|
||||
loginPath: () => loginPath,
|
||||
processPath: () => processPath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> run(List<String> positional) => d.dispatch(IpcRequest(id: '1', cmd: 'env.path', args: {'positional': positional}));
|
||||
|
||||
test('registered on the dispatcher → shows in capabilities', () async {
|
||||
wire();
|
||||
final caps = await d.dispatch(IpcRequest(id: 'c', cmd: 'capabilities', args: const {}));
|
||||
expect((caps.data['commands'] as Map).containsKey('env.path'), isTrue);
|
||||
});
|
||||
|
||||
group('set', () {
|
||||
test('replaces the preset, de-duplicated, and publishes', () async {
|
||||
wire();
|
||||
existingDirs.add('/opt/go/bin');
|
||||
final r = await run(['set', '/opt/go/bin', '/x', '/opt/go/bin']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['dirs'], ['/opt/go/bin', '/x']);
|
||||
expect(r.data['missing'], ['/x'], reason: 'nonexistent dirs warn, never error');
|
||||
expect(store.byCwd['/repo'], ['/opt/go/bin', '/x']);
|
||||
expect(published.single.channel, envPathChannel);
|
||||
expect(published.single.data['action'], 'set');
|
||||
expect(published.single.data['dirs'], ['/opt/go/bin', '/x']);
|
||||
});
|
||||
|
||||
test('expands ~/ against HOME and strips trailing slashes', () async {
|
||||
wire();
|
||||
final r = await run(['set', '~/go/bin/', '~']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['dirs'], ['/home/u/go/bin', '/home/u']);
|
||||
});
|
||||
|
||||
test('relative paths error; empty set points at clear', () async {
|
||||
wire();
|
||||
final rel = await run(['set', 'go/bin']);
|
||||
expect(rel.ok, isFalse);
|
||||
expect(rel.error?.message, contains('not an absolute path'));
|
||||
final empty = await run(['set']);
|
||||
expect(empty.ok, isFalse);
|
||||
expect(empty.error?.hint, contains('clear'));
|
||||
});
|
||||
|
||||
test('add and remove apply the same guards as set (relative + separator)', () async {
|
||||
wire();
|
||||
for (final action in ['set', 'add', 'remove']) {
|
||||
final rel = await run([action, 'go/bin']);
|
||||
expect(rel.ok, isFalse, reason: '$action relative');
|
||||
expect(rel.error?.message, contains('not an absolute path'));
|
||||
final sep = await run([action, '/a:']);
|
||||
expect(sep.ok, isFalse, reason: '$action separator');
|
||||
expect(sep.error?.message, contains('PATH separator'));
|
||||
}
|
||||
expect(store.byCwd, isEmpty, reason: 'nothing was written');
|
||||
});
|
||||
|
||||
test('~ with no HOME errors instead of storing a broken entry', () async {
|
||||
wire(home: null);
|
||||
final r = await run(['set', '~/go/bin']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('cannot expand'));
|
||||
});
|
||||
|
||||
test('a leading-dash entry is rejected by the schema (T-104 guard)', () async {
|
||||
wire();
|
||||
final r = await d.dispatch(
|
||||
IpcRequest(
|
||||
id: '1',
|
||||
cmd: 'env.path',
|
||||
args: {
|
||||
'positional': ['set'],
|
||||
'flags': {'dirs': '--evil'},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(r.ok, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('add / remove / clear', () {
|
||||
test('add appends without duplicating; remove drops; clear empties the key', () async {
|
||||
wire();
|
||||
await run(['set', '/a']);
|
||||
published.clear();
|
||||
|
||||
var r = await run(['add', '/b', '/a']);
|
||||
expect(r.data['dirs'], ['/a', '/b']);
|
||||
|
||||
r = await run(['remove', '/a']);
|
||||
expect(r.data['dirs'], ['/b']);
|
||||
|
||||
r = await run(['clear']);
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['dirs'], isEmpty);
|
||||
expect(store.byCwd.containsKey('/repo'), isFalse, reason: 'empty preset removes the key');
|
||||
expect(published.map((p) => p.data['action']), ['add', 'remove', 'clear']);
|
||||
});
|
||||
|
||||
test('add and remove require a <dir>; removing an absent entry errors', () async {
|
||||
wire();
|
||||
expect((await run(['add'])).ok, isFalse);
|
||||
expect((await run(['remove'])).ok, isFalse);
|
||||
final r = await run(['remove', '/ghost']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('no preset entry matches'));
|
||||
});
|
||||
});
|
||||
|
||||
group('list', () {
|
||||
test('returns the preset, the missing subset, and the effective PATH preview', () async {
|
||||
wire();
|
||||
existingDirs.add('/a');
|
||||
await run(['set', '/a', '/gone']);
|
||||
final r = await run(['list']);
|
||||
expect(r.data['root'], '/repo');
|
||||
expect(r.data['dirs'], ['/a', '/gone']);
|
||||
expect(r.data['missing'], ['/gone']);
|
||||
expect(r.data['effectivePath'], startsWith('/a:/gone:'), reason: 'preset prepends the resolved PATH');
|
||||
});
|
||||
});
|
||||
|
||||
group('capture', () {
|
||||
test('suggests login-shell dirs the process PATH lacks, minus the preset', () async {
|
||||
wire();
|
||||
loginPath = '/brew/bin:/usr/bin:/opt/go/bin:/bin';
|
||||
await run(['set', '/brew/bin']);
|
||||
final r = await run(['capture']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['suggested'], ['/opt/go/bin']);
|
||||
expect(r.data['loginShellPath'], loginPath);
|
||||
expect(r.data['processPath'], '/usr/bin:/bin');
|
||||
});
|
||||
|
||||
test('no login-shell probe → empty suggestions with an honest note', () async {
|
||||
wire();
|
||||
final r = await run(['capture']);
|
||||
expect(r.data['suggested'], isEmpty);
|
||||
expect(r.data['note'], contains('unavailable'));
|
||||
});
|
||||
});
|
||||
|
||||
test('un-injected production seams (HOME / dir probe / process PATH) work', () async {
|
||||
store = _FakeStore();
|
||||
d = DaemonDispatcher();
|
||||
registerEnvPathCommands(d, () => store, workspaceCwd: () => '/repo');
|
||||
final set = await d.dispatch(
|
||||
IpcRequest(
|
||||
id: '1',
|
||||
cmd: 'env.path',
|
||||
args: const {
|
||||
'positional': ['set', '/definitely-missing-dir'],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(set.ok, isTrue, reason: set.error?.message);
|
||||
expect(set.data['missing'], ['/definitely-missing-dir'], reason: 'real Directory probe ran');
|
||||
final cap = await d.dispatch(
|
||||
IpcRequest(
|
||||
id: '2',
|
||||
cmd: 'env.path',
|
||||
args: const {
|
||||
'positional': ['capture'],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(cap.ok, isTrue);
|
||||
expect(cap.data['processPath'], isA<String>());
|
||||
});
|
||||
|
||||
test('no workspace / no store / unknown action error clearly', () async {
|
||||
wire(cwd: null);
|
||||
expect((await run(['list'])).error?.message, contains('no workspace'));
|
||||
wire(withStore: false);
|
||||
expect((await run(['list'])).error?.message, contains('unavailable'));
|
||||
wire();
|
||||
final r = await run(['frobnicate']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.hint, contains('list | set | add | remove | clear | capture'));
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,9 @@ void main() {
|
||||
final target = '${parent.path}/my-app';
|
||||
expect(r.path, target);
|
||||
expect(Directory(target).existsSync(), isTrue);
|
||||
expect(File('$target/.gitignore').existsSync(), isTrue);
|
||||
final ignored = File('$target/.gitignore').readAsStringSync();
|
||||
expect(ignored, contains('.clide/'));
|
||||
expect(ignored, contains('.worktrees/'), reason: 'in-repo worktree checkouts stay out of git (D-106)');
|
||||
expect(File('$target/CLAUDE.md').readAsStringSync(), contains('my-app'));
|
||||
expect(inited, [target]);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,16 @@ void main() {
|
||||
expect(published.single.data, {'path': 'lib/main.dart'});
|
||||
});
|
||||
|
||||
test('canvas keys on path and publishes a builtin.canvas selection (T-322)', () async {
|
||||
wire();
|
||||
final r = await open(['canvas', 'notes/board.canvas']);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['opened'], isTrue);
|
||||
expect(published.single.publisher, 'builtin.canvas');
|
||||
expect(published.single.channel, 'selection');
|
||||
expect(published.single.data, {'path': 'notes/board.canvas'});
|
||||
});
|
||||
|
||||
test('named args work too (reader/ref)', () async {
|
||||
wire();
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'ui.open', args: {'reader': 'tickets', 'ref': 'T-7'}));
|
||||
@@ -68,7 +78,7 @@ void main() {
|
||||
|
||||
test('unknown reader → userError, nothing published', () async {
|
||||
wire();
|
||||
final r = await open(['canvas', 'foo']);
|
||||
final r = await open(['bogus', 'foo']);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(published, isEmpty);
|
||||
|
||||
@@ -61,7 +61,7 @@ void main() {
|
||||
|
||||
test('built-in set hides clide-owned dirs', () {
|
||||
final s = IgnoreSet.builtin();
|
||||
for (final d in const ['.git', '.pql', '.clide', '.dart_tool', 'build', 'node_modules']) {
|
||||
for (final d in const ['.git', '.pql', '.clide', '.dart_tool', '.worktrees', 'build', 'node_modules']) {
|
||||
expect(s.isIgnored(d, isDirectory: true), isTrue, reason: d);
|
||||
}
|
||||
expect(s.isIgnored('lib', isDirectory: true), isFalse);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/// openWorkspaceFile (T-51/T-187) — the single dispatch point for file
|
||||
/// activations: `.md` → markdown reader, `.canvas` → canvas pane (T-322),
|
||||
/// everything else → the editor via IPC. Also records recents.
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
late List<Message> published;
|
||||
late List<Object?> editorOpens;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
published = [];
|
||||
editorOpens = [];
|
||||
f.services.messages.subscribe().listen(published.add);
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
editorOpens.add(args['path']);
|
||||
return IpcResponse.ok(id: '1', data: const {});
|
||||
});
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<void> deliver() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
test('.md routes to the markdown reader via the bus', () async {
|
||||
openWorkspaceFile(f.services, 'docs/plan.md');
|
||||
await deliver();
|
||||
expect(published, hasLength(1));
|
||||
expect(published.single.publisher, 'builtin.markdown');
|
||||
expect(published.single.channel, 'selection');
|
||||
expect(published.single.data, {'path': 'docs/plan.md'});
|
||||
expect(editorOpens, isEmpty);
|
||||
});
|
||||
|
||||
test('.canvas routes to the canvas pane via the bus (T-322)', () async {
|
||||
openWorkspaceFile(f.services, 'notes/board.canvas');
|
||||
await deliver();
|
||||
expect(published, hasLength(1));
|
||||
expect(published.single.publisher, 'builtin.canvas');
|
||||
expect(published.single.channel, 'selection');
|
||||
expect(published.single.data, {'path': 'notes/board.canvas'});
|
||||
expect(editorOpens, isEmpty);
|
||||
});
|
||||
|
||||
test('extension match is case-insensitive', () async {
|
||||
openWorkspaceFile(f.services, 'BOARD.CANVAS');
|
||||
await deliver();
|
||||
expect(published.single.publisher, 'builtin.canvas');
|
||||
});
|
||||
|
||||
test('everything else opens in the editor over IPC', () async {
|
||||
openWorkspaceFile(f.services, 'lib/main.dart');
|
||||
await deliver();
|
||||
expect(published, isEmpty);
|
||||
expect(editorOpens, ['lib/main.dart']);
|
||||
});
|
||||
|
||||
test('records the open in recent files; empty path is a no-op', () async {
|
||||
openWorkspaceFile(f.services, 'notes/board.canvas');
|
||||
openWorkspaceFile(f.services, '');
|
||||
await deliver();
|
||||
expect(f.services.recentFiles.paths, ['notes/board.canvas']);
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,20 @@ void main() {
|
||||
loaded.dispose();
|
||||
});
|
||||
|
||||
test('numeric-shaped key segments round-trip unmangled (workspace-hash keys)', () async {
|
||||
// FNV workspace-hash suffixes (app.env.pathPrepend.<hash>, the account
|
||||
// bindings) can be number-shaped; an unquoted YAML key would reload as
|
||||
// an int (leading zero dropped) or a float ('1e…' → Infinity) and
|
||||
// silently orphan the stored value.
|
||||
await store.set<List<String>>('app.env.pathPrepend.0123456789012345', const ['/opt/go/bin']);
|
||||
await store.set<String>('app.claude.account.1e23456789012345', 'work');
|
||||
final loaded = SettingsStore(appDir: tmp);
|
||||
await loaded.load();
|
||||
expect(loaded.get<List<dynamic>>('app.env.pathPrepend.0123456789012345'), ['/opt/go/bin']);
|
||||
expect(loaded.get<String>('app.claude.account.1e23456789012345'), 'work');
|
||||
loaded.dispose();
|
||||
});
|
||||
|
||||
test('app.* scope supports bool + int + list', () async {
|
||||
await store.set<bool>('app.extensions.git.enabled', false);
|
||||
await store.set<int>('app.layout.width', 240);
|
||||
|
||||
@@ -99,6 +99,32 @@ void main() {
|
||||
expect(pane.kind, PaneKind.claude);
|
||||
expect(pane.toJson()['kind'], 'claude');
|
||||
});
|
||||
|
||||
test('pathForSpawn hook sets the child PATH per cwd (D-106)', tags: ['pty'], () async {
|
||||
String? seenCwd;
|
||||
final preset = PaneRegistry(
|
||||
events: sink,
|
||||
pathForSpawn: (cwd) {
|
||||
seenCwd = cwd;
|
||||
return '/preset-marker:/usr/bin:/bin';
|
||||
},
|
||||
);
|
||||
addTearDown(preset.shutdown);
|
||||
|
||||
final buf = StringBuffer();
|
||||
final got = Completer<void>();
|
||||
final sub = sink.stream.listen((e) {
|
||||
if (e.kind != 'pane.output') return;
|
||||
buf.write(utf8.decode(base64Decode(e.data['bytes_b64']! as String)));
|
||||
if (buf.toString().contains('/preset-marker') && !got.isCompleted) got.complete();
|
||||
});
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await preset.spawn(kind: PaneKind.terminal, argv: const ['/bin/sh', '-c', r'printf %s "$PATH"; sleep 0.25'], cwd: '/tmp');
|
||||
|
||||
await got.future.timeout(ioTimeout, onTimeout: () => fail('child PATH never carried the preset marker within ${ioTimeout.inSeconds}s'));
|
||||
expect(seenCwd, '/tmp');
|
||||
});
|
||||
});
|
||||
|
||||
group('RecordingEventSink filters', () {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user