36 Commits
Author SHA1 Message Date
jpmschweitzerandClaude ad807fb0df fail dart doc CI on validate-links warnings (T-17)
test / unit + widget + golden + a11y (push) Failing after 41s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m22s
Wraps the `dart doc --validate-links` step so any warning fails the
job, not just hard errors. The previous step exited 0 even with
broken doc refs and dangling README links — exactly the
informational-mode drift that lets a clean board rot.

Updates the CHANGELOG entry to describe the gate accurately (the
earlier wording overstated `--validate-links`, which only prints).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 17:58:27 +02:00
jpmschweitzerandClaude 5ee759d0fd clear dart doc validate-links warnings
Eight unresolved doc references and broken README-rewritten links
that surfaced under `dart doc --validate-links`:

- Library-scope refs `[spawn]`, `[openProject]` qualified to
  `[Backend.spawn]` / `[Backend.openProject]`; same treatment for
  `[resolvePaths]` / `[applyResolved]` on Toolchain.
- `[D-41]` was a decision ID, not a Dart symbol — drop the brackets.
- `[from]` from I18n.interpolated qualified to `[I18nReplacer.from]`.
- `[DefaultSurfaceMap]` was a stale name (private `_defaultSurfaceMap`
  in resolver.dart); switch to backticked path reference since
  dartdoc can't link private members.
- `[icons/]` was a directory, not a symbol; backticked path.
- README links to `legacy/`, `docs/initial-plan.md`, `decisions/`,
  `LICENSE` rewritten as absolute github.com/postmeridiem/clide URLs
  so dartdoc stops re-rooting them into the doc tree.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 17:58:18 +02:00
jpmschweitzerandClaude eda5d04710 gitignore generated dart doc output
`dart doc` writes the rendered API site to `doc/api/`. The CI step
uploads it as an artefact; locally it's regenerated on every run and
should never land in the tree.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 17:58:06 +02:00
jpmschweitzerandClaude 329aa8a360 add whats-next skill for batch ticket selection
Walks the pql initiative/epic tree, filters to unblocked tickets,
optionally refines context via parallel agents, and transitions a
confirmed batch to in_progress. Mirrors the existing pql skill's
place in the planning flow so /whats-next is the natural counterpart
to "what's the plan status".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 17:48:03 +02:00
jpmschweitzerandClaude 9945290bdd wire dart doc into staged CI workflow (T-17)
Adds a docs job to .gitea/workflows/test.yml that runs
`dart doc --validate-links` and uploads doc/api/ as an artefact.
Runs in parallel with unit; documents the public lib/ surface and
fails the build on broken references. Stays inert with the rest of
the workflow until Gitea Actions activates per D-32.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 17:47:52 +02:00
jpmschweitzerandClaude 4242cdc490 share .pql/hooks/ across clones
The pql plan auto-export (pre-commit) and auto-import (post-merge)
hooks were previously gitignored as part of `.pql/*`. Allowing the
hooks directory to be committed means a fresh clone gets the
planning-state sync without needing to run `pql init` first — pql
plan snapshots stay current on push and absorb changes on pull
the same way for every contributor.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 17:18:24 +02:00
jpmschweitzerandClaude 6601056972 finish T-87: cold-start reap, kill-all-sessions, helper tests
Three remaining acceptance criteria for T-87:

1. Cold-start reap. The Claude extension's activate() now kills
   every leftover secondary tmux session for the current repo
   before any new spawn. activate runs before any UI mounts, so
   _nextSecondary's starting value of 1 is correct even when a
   previous run died abruptly (kill -9, OOM, force-quit). The
   deactivate() hook also calls reapSecondaries as a courtesy on
   explicit extension teardown — but Flutter's deactivate doesn't
   fire on app quit, so activate is the load-bearing path.

2. claude.kill-all-sessions actually kills server-side. The
   command previously called pane.close on every claude pane,
   which only kills the tmux client. It now also calls
   tmux.killAllForRepo to kill the sessions on the clide socket.

3. Tests. test/builtin/claude/tmux_session_test.dart covers
   killSession, listClideSessions, reapSecondaries, and
   killAllForRepo via the TmuxRunner override — no real shell-out
   in tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 16:48:06 +02:00
jpmschweitzerandClaude 1c424f26e7 kill secondary Claude tmux sessions on tab close (T-87)
Adds lib/builtin/claude/src/tmux_session.dart with helpers for the
clide-socket tmux server: killSession, listClideSessions,
reapSecondaries, killAllForRepo. The runner is overrideable via a
TmuxRunner typedef so tests don't shell out for real.

Wires ClaudePane.dispose() to call killSession(sessionName) for
secondary panes. Primary panes are left alone — D-41 keeps the
primary's tmux session alive across clide restarts so the next
launch re-attaches via `tmux new-session -A`.

Imports the helpers in the Claude extension as groundwork for the
app-shutdown reap and the existing claude.kill-all-sessions
command — wiring those uses lands separately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 16:41:33 +02:00
jpmschweitzerandClaude 6e6546fe32 clean-house sweep: anchor drift, app/-prefix paths, D-59 sunset
Fixes 3 substring-truncated cross-reference anchors so they match
the full heading slug:
- D-3 link in architecture.md
- D-40 link in process.md (heading gained the [SUPERSEDED] tag)
- Q-15 link in questions-process.md

Strips the legacy `app/` prefix from path references in 5 files —
the dirs were flattened to repo root in the Flutter rebuild
(D-56). Three "was `app/...`" historical references in D-5 and
D-56 are deliberately preserved as record of the dissolution.

Adds an inline (tracked in T-88) note to D-59 so the
"must track dugite-native releases for security updates" intent
is wired to a backlog item — RULE-SUNSET-WITHOUT-TICKET would
otherwise keep flagging it on every sweep.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 16:40:38 +02:00
jpmschweitzerandClaude e329f1cc6f codify UI spacing constants (T-86)
Adds lib/widgets/src/spacing.dart with three categories of named
constants — insets (clideInsetHairline / Tight / Icon / Standard /
Text), gaps (clideGapTight / Standard / Section / SectionLarge /
Major / Column), and sizes (clideIconMicro / Caption / Standard /
HitTarget / Emphatic, clideControlHeight).

Migrates MultitabPane to consume the constants and updates the
ui-design geometry reference to point at them. Inline pixel
literals in widget code were drifting (12 here, 6 there, 28
elsewhere) — pulling them through named symbols makes the
"uniform inner spacing" rule enforceable instead of eyeballed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 15:45:07 +02:00
jpmschweitzerandClaude 29ac9b3caf consolidate theme-ui into ui-design skill (T-85)
Replaces the single-file theme-ui skill with a routed ui-design skill
backed by four references:

- theme.md      — token system, identity rule, palette layers, type
- surface.md    — token selection per surface (chrome, panels, tabs,
                  buttons, status, overlays)
- geometry.md   — control spacing/alignment principles distilled from
                  the MultitabPane work: uniform inner spacing rule,
                  no double-edge padding, two-column control pattern,
                  perceived mass over measured pixels
- icons.md      — Phosphor icons + clide-owned painters

SKILL.md routes to the right reference and holds the universal rules.
The trigger description widens to cover spacing/alignment questions
in addition to token selection.

geometry.md references T-86 (codify spacing constants); the doc uses
literal pixel values until those constants land.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 15:38:19 +02:00
jpmschweitzerandClaude 71b21e1974 wire Claude pane to MultitabPane (T-24)
ClaudeSessionHost replaces its bespoke tab strip / add button /
close handler with a MultitabPane<_Session> in keepAlive mode.
The primary tab is seeded as non-closeable and non-reorderable
per D-41; secondaries spawn via the existing addSecondary()
entry point and gain drag-to-reorder for free.

Drops ~100 lines of custom _TabRow / _Tab / _AddButton code in
favour of the shared widget. Behaviour is preserved: primary
persists across clide restarts, secondaries can be closed, and
PTY state survives tab switches because IndexedStack keeps every
ClaudePane mounted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 15:21:08 +02:00
jpmschweitzerandClaude 96c6cfc6c5 MultitabPane: keepAlive mode and tab strip polish
Adds keepAlive: when true, all entry bodies stay mounted via
IndexedStack so switching tabs doesn't tear down their state.
Hosts that own PTY-backed sessions or any long-lived widget
state opt in; callers that want fresh state on each switch use
the default single-body mode.

Polishes the tab strip itself for production use:
- bottom divider so the strip visually anchors to the body below
- Column.crossAxisAlignment.stretch so the strip fills the pane
  width instead of sizing to its content
- close button: replace the text × glyph with the CloseIcon
  painter (clean cross strokes, font-independent)
- two-column tab layout — Expanded title on the left, fixed
  16x16 close button on the right; uniform 12px left padding,
  6px right padding to match the 6px top/bottom breathing room
  around the close button

Two new widget tests cover keepAlive (state preserved across
switches) and default mode (inactive bodies disposed).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 15:20:57 +02:00
jpmschweitzerandClaude aa742eed79 MultitabPane: drag-to-reorder gesture wiring (T-84)
Each tab is wrapped in a Draggable (when allowReorder is true and the
entry itself is reorderable) and a DragTarget (always — the controller's
barrier logic decides whether the move actually happens). Drops insert
the dragged entry at the target tab's index. A 2px leading insertion
indicator highlights the active drop target.

The widget harness now wraps children in an Overlay so Draggable's
feedback can mount without each test re-wrapping. Sized by the test
view's bounds to avoid disturbing existing tests that query
find.byType(SizedBox).first.

Four widget tests cover the gesture path: drop reorders, pinned
barrier blocks, pinned tabs aren't draggable, and allowReorder=false
disables drag entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 14:17:16 +02:00
jpmschweitzerandClaude 1caaf5f4dd implement MultitabPane widget + controller (T-83)
MultitabController<T> is a Flutter-free ChangeNotifier owning the
tab list, active selection, and reorder/close invariants:
- pinned (non-reorderable) entries form barriers that other tabs
  cannot cross
- non-closeable entries silently no-op on remove() so hosts don't
  need to gate the call site
- closing the active tab falls right, then left, then to null
- duplicate ids are rejected

MultitabPane<T> is the widget shell: a horizontal tab strip
followed by the active entry's body. Active tab gets the
panelHeader background and a panelActiveBorder top accent;
inactive tabs blend into the tab bar. Close × is hidden until
hover. Add button only renders when onAddRequested is wired.

Hosts route the user's add/close intent through callbacks so the
widget stays domain-free — for the Claude pane, add will spawn a
new tmux session and close will kill one. Drag-to-reorder is
controller-side only for now (the gesture wiring lands with T-24).

19 controller tests + 9 widget tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 12:12:42 +02:00
jpmschweitzerandClaude bdc46a4fdd design multitab pane widget (T-83)
Reusable widget for panes that need N runtime tab instances of the
same kind. First consumer is the Claude pane (primary + 0..N
secondaries per D-41); the generic shape lets other panes adopt it
later without reinventing tab strips.

Includes:
- Design doc with API sketch, rendering, interaction, persistence
  boundary, integration sketch for the Claude pane, and three open
  questions (keyboard scoping for nested cases, overflow, density).
- Wireframe of the Claude pane with primary (pinned) + 2 secondaries
  + add button, accent border on the active tab.
- Architecture diagram (sketch mode) showing the host / widget /
  controller / IPC boundary that keeps the widget domain-free.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 12:04:42 +02:00
jpmschweitzerandClaude 4043c2cbe0 gitignore frame0-wireframe idmap files
Per the frame0-wireframe skill: the per-machine ID mapping file
(*.idmap.json) is local state that lets pull/push reconcile with
Frame0. The committed JSON wireframes are the source of truth.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 11:14:06 +02:00
jpmschweitzerandClaude b0a826cfd4 add wireframes from current implementation; supersede claude-design
Five wireframes generated via the frame0-wireframe skill, sourced
from JSON and rendered to PNG. Cover the welcome screen and four
main-view states: default, editor-above-Claude (D-49), focus mode
(D-52), sidebar-collapsed (D-51), and ticket detail in the context
panel.

The hi-fi mockups under docs/claude-design/ are now reference-only;
README marks the bundle as superseded and points at docs/wireframes/
as the canonical source. The token files there still feed the
runtime themes per D-43 / D-44, so the bundle is kept rather than
removed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 11:13:52 +02:00
jpmschweitzerandClaude 18d4c5978b add frame0-wireframe and d2-diagram skills
Two general-purpose skills for visual design work:
- frame0-wireframe drives Frame0 (local wireframing app) from
  JSON source files, with push/pull/export and a batch script.
- d2-diagram wraps the d2 text-to-diagram CLI for architecture
  and flow diagrams.

Sourced from settled-reach/main where they were already in use.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 11:10:36 +02:00
jpmschweitzerandClaude 2d64740007 welcome screen: add Tips card below START / RECENT row
Six common keybindings (Quick open, Command palette, Toggle
sidebar, Toggle context, Switch theme, New Claude session) shown
as a 3x2 grid card spanning the same 850px content column as the
two action columns above. LayoutBuilder gates the card on viewport
height (>640px) so on shorter windows the centered START / RECENT
columns stay the focus and the tips drop out cleanly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 11:10:17 +02:00
jpmschweitzerandClaude 5f162a6668 rename tagline to "IDE for Claude Code CLI"
clide is an IDE for the Claude Code CLI. The previous tagline
"Flutter desktop IDE for Claude Code" overemphasized the host
toolkit (Flutter is implementation detail, immediately obvious to
contributors) and was ambiguous about whether the integration
target is the CLI specifically.

Updates the welcome subtitle (i18n catalog + widget test + view),
the project description in pubspec.yaml, README and CLAUDE.md, the
CLI banner, and the web manifest/index.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 11:06:41 +02:00
jpmschweitzerandClaude 5fb3df84ed map POSIX errno to actionable IPC error kinds (T-79)
pane.spawn (via PtyException.errno) and editor.open (via
FileSystemException.osError.errorCode) now route ENOENT to
not_found, EACCES/EPERM to user_error with a permissions hint,
EISDIR/ENOTDIR/EEXIST to distinct user-error/conflict, and
EMFILE/ENFILE to tool_error with a "fd limit hit" hint. The
mapping lives in lib/src/ipc/errno_mapping.dart so other handlers
can adopt the same surface as they pick up errno-bearing failures.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 09:56:04 +02:00
jpmschweitzerandClaude 41fd586ca0 IPC server: per-request timeout, error logging, stale-socket guard (T-77)
Three hardening fixes:
- 60s per-request timeout (configurable via DaemonServer constructor)
  prevents a misbehaving handler from blocking the connection's
  read pipeline indefinitely. On timeout the client gets a clean
  tool_error response.
- broadcast() and the per-request response writeln are wrapped in
  try/catch with stderr logging. Previously write failures silently
  dropped clients with no diagnostic; events going missing was
  invisible.
- start() probes for a live daemon before unlinking a stale socket.
  If something answers within 200ms, refuse to start. Previously
  two daemons racing to bind would let the second rip the first's
  live socket out.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 15:12:14 +02:00
jpmschweitzerandClaude 1787147e82 PTY: fix resource leaks and reader-isolate races (T-76)
NativePty.close() now awaits the reader-isolate spawn, kills the
child first to drive EOF on the master fd, awaits the isolate's
EOF acknowledgement, and only then closes the fd. Previously the
fd-close racing with the polling isolate left a window where the
fd number could be reused and the isolate would briefly target the
wrong file.

Both NativePty and PtySession now surface reader-isolate spawn
errors via the output stream's addError instead of silently
swallowing them.

PtySession.spawn closes the master fd on any post-receive failure,
closes parentSock in finally (was leaking on every spawn), and
kills the ptyc process if recvFd fails.

PtySession._recvFdAsync uses try/finally to close the ReceivePort
and kill the spawn isolate even when Isolate.spawn itself throws.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 15:10:43 +02:00
jpmschweitzerandClaude 0d333e8470 PTY: surface errno from forkpty/write/ioctl failures (T-75)
forkpty failures throw PtyException with the captured errno
(previously a generic StateError). The spawned child's chdir/execve
failures write a diagnostic line to its slave PTY before _exit, so
the parent's reader sees "exec failed: <path>" instead of an
indistinguishable EOF.

NativePty.write and PtySession.write loop on short writes and throw
PtyException on hard errors (with errno). NativePty.resize sets
_dead on EBADF so subsequent calls short-circuit cleanly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 15:07:25 +02:00
jpmschweitzerandClaude 816e60d028 reject path traversal in files.read and files.ls (T-78)
Both handlers concatenated the request path onto the workspace root
without validating containment, letting `path: "../../../etc/passwd"`
escape the workspace. resolveUnderRoot normalizes the path and
checks containment under root.absolute.path before any filesystem
access.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 15:02:30 +02:00
jpmschweitzerandClaude b99548a900 add PTY + IPC error-handling audit (T-18)
29 issues across PTY (lib/src/pty/), IPC (lib/src/ipc/), and
daemon command handlers (lib/src/daemon/). 14 critical (silent
failures, resource leaks, races), 8 high (degraded UX/debug),
7 medium (cleanliness). Each item references the follow-up
ticket where the fix lands (T-75 through T-81).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 14:59:14 +02:00
jpmschweitzerandClaude 4ff004790d suppress bold rendering in terminal to keep cell grid stable
Flutter falls back to synthetic bold when JetBrainsMono-Bold isn't
registered, and synthetic bold drifts glyph advance widths enough
to break the monospace cell grid (cursor block lands between
characters, prompts wrap mid-word). Color is enough to convey
emphasis in TUIs; semantic italic and underline still render.

Also drop the temporary `tmux -L clide kill-server` from the
install target — the rapid-iteration loop is no longer needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 14:51:04 +02:00
jpmschweitzerandClaude c378503e26 spawn Claude in fullscreen mode and route mouse-wheel to PgUp/PgDown
Spawn `claude` directly as the tmux command with
CLAUDE_CODE_NO_FLICKER=1 so Claude Code runs in its fullscreen TUI
mode (input box pinned at bottom, owns its own scrollback). Mouse
wheel events are converted to PgUp/PgDown key input — universal
scroll signal that Claude, less, vim normal mode all respect, and
sidesteps the mouse-mode-but-no-scroll dead end where TUI apps
capture mouse without binding the wheel.

Drops the 1000-row tmux canvas + SingleChildScrollView experiment
in favor of viewport-sized tmux and Claude's native bottom-pinning.

Makefile install target now kills the clide tmux server so the
new config takes effect immediately. Marked TEMP — to be removed
once we no longer need the rapid-iteration loop.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 13:08:47 +02:00
jpmschweitzerandClaude 8a5c626892 update licenses for terminal inlining
licenses.yaml: xterm entry changed from dart-package to
inlined-source with derivative-work description. JetBrains Mono
weights updated (Bold/BoldItalic dropped). Terminal LICENSE
clarifies this is a derivative work based on xterm.dart v4.0.0.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 08:52:25 +02:00
jpmschweitzerandClaude 85e275a362 wire Claude pane to clide tmux config and isolated socket
Extract bundled tmux.conf to ~/.config/clide/tmux.conf on first
spawn and pass via -f. Use -L clide for a dedicated tmux server
so clide sessions don't inherit the user's tmux settings.
Terminal maxLines bumped from 5k to 50k.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 08:50:50 +02:00
jpmschweitzerandClaude 9c1597a450 inline terminal emulator, drop xterm.dart dependency
Replace the xterm pub.dev package with owned code under
lib/src/terminal/. Based on xterm.dart v4.0.0 by xuty (MIT).
Quiver LRU replaced with hand-rolled LinkedHashMap cache.
Scrollable removed from TerminalView — scroll events are forwarded
via Listener.onPointerSignal instead. zmodem, debugger, and
suggestion modules stripped as unused.

Also: bundle clide.tmux.conf (no status bar, 50k scrollback,
mouse on, zero escape delay, isolated -L clide socket), bump PTY
read buffer to 64KB, add 2px terminal padding, drop bold
JetBrains Mono registration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 08:50:38 +02:00
jpmschweitzerandClaude d4369aabc3 add D-60 through D-65: POLICY.md rules as decision records
Six new D-records codify rules that lived only in POLICY.md:
D-60 no network on default launch path, D-61 dependency vetting
checklist, D-62 dependency removal process, D-63 vendored binary
rebuild process, D-64 no telemetry (architecture), D-65 license
compatibility matrix. Closes T-28.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 08:49:53 +02:00
jpmschweitzerandClaude 7b30748bf5 rewire daemon services on project open
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
The daemon dispatcher (pql, git, files, editor) was built once at
boot against cwd and never updated. Opening a project from the
welcome screen or directory picker had no effect on the daemon
services — pql couldn't find its index, git operated on the wrong
repo.

Now onProjectOpen rebuilds the full dispatcher against the new
workspace root and swaps it into the InProcessClient. The IPC
client's dispatcher field is no longer final.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 22:29:03 +02:00
jpmschweitzerandClaude a5221792ba fix sidebar order and ptyc status bar warning
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Revert priority sort in tabsFor() — registration order in
main.dart is the intended sidebar order, not priority. The
priority fields on extensions were dead code.

Remove ptyc from toolchain missing list since NativePty
replaced it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 22:22:23 +02:00
jpmschweitzerandClaude 79342277b8 fix desktop integration and rename Linux runner
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
StartupWMClass now matches the GTK application ID
(net.schweitz.clide) so the running window groups with the
launcher icon. Desktop file installs as
net.schweitz.clide.desktop per freedesktop convention.

Renamed my_application.{cc,h} to clide_app.{cc,h} with
matching GObject type (ClideApp / CLIDE_APP).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 22:12:05 +02:00
175 changed files with 21329 additions and 860 deletions
+151
View File
@@ -0,0 +1,151 @@
---
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.
@@ -0,0 +1,212 @@
# 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 |
@@ -0,0 +1,251 @@
# 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 }
```
+84
View File
@@ -0,0 +1,84 @@
#!/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"
+92
View File
@@ -0,0 +1,92 @@
#!/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)"
+197
View File
@@ -0,0 +1,197 @@
---
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
1. **Health check** — verify Frame0 is running
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`
3. **Push to Frame0** — `frame0-sync.py push <file.json>`
4. **Export PNG** — `frame0-sync.py export <file.json> <output.png>`
5. **Clean up** — delete test/scratch pages from Frame0 when done
### 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.
@@ -0,0 +1,241 @@
# 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 |
@@ -0,0 +1,467 @@
# 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
}
}
}
```
@@ -0,0 +1,53 @@
# 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 |
+253
View File
@@ -0,0 +1,253 @@
#!/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
@@ -0,0 +1,159 @@
#!/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
+414
View File
@@ -0,0 +1,414 @@
#!/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()
-209
View File
@@ -1,209 +0,0 @@
---
name: theme-ui
description: >-
Token selection guide for clide UI development. Use when building or
modifying widgets, panels, pane chrome, status indicators, icons, or
any visual surface. Ensures correct background, border, text, and hover
tokens are applied per surface type. Triggers on: new widget code,
theme-related changes, "which token", "what color", color/background
questions, visual inconsistency fixes, new panel/pane/view development,
adding or looking up Phosphor icons, icon codepoints.
---
# Theme-UI — token selection for clide surfaces
All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
Never hardcode colors. Never use Material/Cupertino color constants.
## Token selection by surface
Pick tokens based on **where** the widget lives, not what it does.
### Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
```
background → chromeBackground
text → chromeForeground
border → chromeBorder (1px)
active text → globalForeground
```
### Side panels (sidebar, context panel)
```
background → chromeBackground (both sides — they're chrome frame)
text → sidebarForeground
hover → sidebarItemHover
selected → sidebarItemSelected
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
```
Padding: 2px on outer edges, 0px on divider edge.
### Center column (workspace, Claude pane, editor)
```
background → panelBackground
text → globalForeground
```
No padding — content fills edge to edge.
### Pane headers (`ClidePaneChrome`)
```
background → panelHeader
text (title) → panelHeaderForeground
text (sub) → globalTextMuted
```
### List items (decisions, tickets, file rows, backlinks)
```
background → (none / transparent)
hover bg → listItemHoverBackground
selected bg → listItemSelectedBackground
text → listItemForeground / sidebarForeground (in sidebar)
selected txt → listItemSelectedForeground
```
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
### Buttons
```
normal → buttonBackground / buttonForeground / buttonBorder
hover → buttonHoverBackground
active → buttonActiveBackground
primary → buttonActiveBackground bg + globalBackground text
subtle → listItemBackground / listItemHoverBackground (no border)
```
### Dividers and separators
```
line → dividerColor (always, everywhere)
drag handle → 8px hit area, 1px visible line, panel bg fill
hover line → panelActiveBorder
```
### Status indicators
```
success/ok → statusSuccess (green: done, added, connected)
warning → statusWarning (amber: question, modified, missing)
error → statusError (red: deleted, rejected, cancelled)
info → statusInfo (blue: in_progress, modified)
```
Map semantic states, not visual styles:
- `done` / `added` / `ok` → `statusSuccess`
- `in_progress` / `modified` → `statusInfo`
- `question` / `warning` → `statusWarning`
- `cancelled` / `deleted` / `error` → `statusError`
### Overlays (dialogs, palette, tooltips)
```
dialog bg → modalSurfaceBackground
dialog border→ modalSurfaceBorder
backdrop → modalOverlayBackground
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
```
## Typography
Three constants — never hardcode sizes or families.
```
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
family mono → clideMonoFamily (JetBrainsMono)
body size → clideFontBody (15)
caption size → clideFontCaption (14) — status bar, section headers, git info
mono size → clideFontMono (14) — terminal, code, paths, IDs
```
Use `ClideText` for themed text. Set `muted: true` for secondary text
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
## Token identity rule
Every visual surface gets its own named token. Never borrow a token from
another surface just because they happen to resolve to the same color.
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
**Right:** Create `chromeBackground` that resolves to the same palette key.
When two surfaces share a color:
1. **If they're the same conceptual surface** (sidebar + context panel are
both "side panels") → one shared token set is fine.
2. **If they're different surfaces that happen to match** (hat bar + sidebar
+ status bar are all "chrome frame") → create a shared primitive in the
palette/semantic layer (e.g. `bgChrome`) and give each surface its own
token that maps to that primitive. This lets themes diverge them later.
The palette layer has these depth primitives:
- `bg` (`#20202C`) — outermost root, behind everything
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
- `surface` (`#242838`) — elevated: pane headers, active tabs
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
Chrome tokens (`chromeBackground`/`chromeForeground`/`chromeBorder`) are
the shared root for all frame surfaces. They resolve to `bgSunken` /
`textDim` / `border` in the palette. Themes can override them to diverge
hat from sidebar from status bar if desired.
## Extension-owned domain colors
Extensions that need domain-specific color coding (ticket types, decision
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
1. Create a color map class in the extension (e.g. `TicketTypeColors`)
2. Ship dark and light presets, auto-selected via `ClideTheme.of(context).dark`
3. Store user overrides under `ext.<id>.colors` in settings
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`
This keeps the core token surface lean and lets each extension own its
palette. The pattern scales to any extension needing domain colors.
## Icons — Phosphor Icons
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
`assets/fonts/phosphor/` (regular, bold, fill weights).
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` —
full mapping of all 1512 icon codepoints to kebab-case and PascalCase
names. Read this file to look up any icon by name or codepoint.
**Adding an icon:** find the codepoint in `codepoints.csv`, then add
a `static const` entry to `PhosphorIcons` in
`lib/widgets/src/icons/phosphor.dart`:
```dart
static const arrowClockwise = PhosphorIconPainter(0xe036);
```
Only add icons we actually use — don't bulk-import the full set.
**Using an icon:** `ClideIcon(PhosphorIcons.arrowClockwise, size: 13)`
or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
Fill weight: `family: 'Phosphor-Fill'`.
## Anti-patterns
- Borrowing another surface's token (`sidebarBackground` for hat bar)
- `globalBackground` for panel fill → use `panelBackground`
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
- Hardcoded `Color(0xFF...)` → use a token
- `fontSize: 14` → use `clideFontCaption` or `clideFontMono`
- `fontFamily: 'JetBrainsMono'` → use `clideMonoFamily`
## Reference
Full token list: `lib/kernel/src/theme/tokens.dart`
Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
+51
View File
@@ -0,0 +1,51 @@
---
name: ui-design
description: >-
Visual design guide for clide UI development — covers theme tokens,
surface-specific token selection, control geometry/spacing/alignment,
and Phosphor icons. Use when building or modifying widgets, panels,
pane chrome, status indicators, tabs, list items, dialogs, or any
visual surface. Triggers on: new widget code, theme-related changes,
"which token", "what color", color/background questions, visual
inconsistency fixes, "alignment off", "spacing", "padding", control
geometry questions, new panel/pane/view development, adding or
looking up Phosphor icons, icon codepoints.
---
# UI design — clide visual surface guide
This skill bundles four concerns that all surface in widget work:
| Concern | Reference | When to read |
|---------|-----------|--------------|
| Token system, palette, typography | [`references/theme.md`](references/theme.md) | Designing or extending the theme pipeline; deciding whether to add a new token |
| Token selection per surface | [`references/surface.md`](references/surface.md) | Building a new widget or modifying an existing one — "which token does this need" |
| Spacing, alignment, control layout | [`references/geometry.md`](references/geometry.md) | Building tab strips, list items, buttons, anything where icons sit next to text or padded edges |
| Phosphor icon usage and codepoints | [`references/icons.md`](references/icons.md) | Adding or referencing an icon |
Read the reference that matches the question. They cross-reference each
other where relevant; you don't need to read all four.
## Universal rules
These apply across every reference and every surface:
- All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
Never hardcode `Color(0xFF...)`.
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
is `WidgetsApp` only (D-7).
- Use `ClideText` for themed text; never bare `Text` in production widgets.
- Typography: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
status/section headers, body inherits from `DefaultTextStyle`.
## Anti-patterns (cross-cutting)
- Borrowing another surface's token (`sidebarBackground` for hat bar) — give
each surface its own token even if they share a palette key. See `theme.md`.
- Hardcoded hex colors → use a token. See `surface.md` for which one.
- `fontSize: 14` literal → use `clideFontCaption` or `clideFontMono`.
- `fontFamily: 'JetBrainsMono'` literal → use `clideMonoFamily`.
- Stacking edge padding on a padded parent + a padded child action → see
`geometry.md` "no double edge padding".
- Eyeballing pixel margins instead of working back from the constraint —
the math matters; see `geometry.md` "uniform inner spacing".
@@ -0,0 +1,181 @@
# Geometry — spacing, alignment, control layout
Principles for placing icons, buttons, and text inside controls.
Apply when building tab strips, list items, buttons with affordances,
or anything where actions sit next to content.
> Constants live in `lib/widgets/src/spacing.dart` — pull from there
> instead of inlining literals:
>
> | Concept | Constant |
> |-------------------------|-------------------------|
> | Hairline (2px) | `clideInsetHairline` |
> | Tight inset (4px) | `clideInsetTight` |
> | Uniform icon margin (6) | `clideInsetIcon` |
> | Standard inset (8px) | `clideInsetStandard` |
> | Text-content inset (12) | `clideInsetText` |
> | Tight / standard gap | `clideGapTight` / `clideGapStandard` |
> | Section / major gap | `clideGapSection` / `clideGapMajor` |
> | Micro icon (10) | `clideIconMicro` |
> | Standard icon (14) | `clideIconStandard` |
> | Hit-target (16) | `clideIconHitTarget` |
> | Control height (28) | `clideControlHeight` |
## Uniform inner spacing rule
Icons inside control surfaces should have **equal margin on every
constrained side**. The "constrained sides" are top, bottom, and the
side opposite to where content flows in.
The remaining side — where the text or other content sits — gets a
larger, content-appropriate breathing room.
Example: tab close button (16×16 inside a 28-tall tab):
```
top : 6 ┐
bottom : 6 ├─ uniform: (28 − 16) / 2 = 6
right : 6 ┘
left : 8 ── content gap (separates from title text)
```
The visual effect: the close button looks like a deliberate
affordance with a calm, consistent border, not a glyph stuffed into
the corner.
## No double-edge padding
When a fixed-size action (icon button, close ×) sits at the edge of
a padded parent, the parent's padding on that edge should **not stack**
with the action's own internal margin. Pick one place to hold the
breathing room.
Wrong:
```dart
Container(
padding: EdgeInsets.symmetric(horizontal: 12), // tab pad: 12 right
child: Row(children: [
Expanded(child: title),
SizedBox(width: 8), // gap: 8
Container(width: 28, alignment: Center, // close: 6 internal margin
child: Icon(close, size: 16)),
]),
)
// Visible margin from icon right to outer right = 12 + 6 = 18px → too much
```
Right:
```dart
Container(
padding: EdgeInsets.only(left: 12, right: 6), // pad matches icon margin
child: Row(children: [
Expanded(child: title),
SizedBox(width: 8),
Container(width: 16, height: 16, alignment: Center, // hit target = icon size
child: Icon(close, size: 10)),
]),
)
// Visible margin = 6 (parent right pad) ≈ 6 (top/bottom auto) → uniform
```
## Two-column control pattern
For tab-shaped or row-shaped controls with a primary content area and
a secondary action:
```dart
Row(children: [
Expanded(child: <content>), // takes remainder
if (action != null) ...[
SizedBox(width: 8), // standard gap
<fixed-size action>, // shrinks to content
],
])
```
- **Left column**: `Expanded`, holds the primary content (title,
label, description). Aligned to the start of its space by default.
- **Right column**: fixed natural width, holds the action (close,
status, indicator). Sized to the icon, not to artificial padding.
The parent container's padding sits flush against both columns (see
"no double-edge padding").
## Match perceived mass, not measured pixels
Glyphs vary in visual weight. A bold `+` looks heavier than a thin
`×` at the same point size. When eyeballing alignment, trust the
optical center over the geometric center.
In practice: if two icons measure to the same margin but one *looks*
crowded, give the heavier glyph slightly more breathing room and
trim the lighter one. For clide, this came up with the `×` close
glyph vs the `+` add glyph — both at 14pt, but `+` reads as denser
and is left in its 28-wide button without further padding, while
`×` sits in a 16×16 hit area with 6px symmetric margin.
## Strip / row should fill the parent
Tab strips, status bars, and divider rows should span the full
parent width, not size to their content. Without this, the strip
looks like it floats inside the pane.
```dart
Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // <-- this
children: [
_TabStrip(...),
Expanded(child: _body(...)),
],
)
```
Without `stretch`, Column gives loose width constraints and a
`Container(height: tabHeight)` child sizes to its child's natural
width — the strip ends mid-pane.
## Anchor strips with a divider
Add a 1px bottom border (`dividerColor`) to tab strips and any
header strip that sits above content. Without it, the strip looks
disconnected from the body and the perceived alignment slips.
```dart
Container(
height: 28,
decoration: BoxDecoration(
color: tokens.tabBarBackground,
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
),
child: ...,
)
```
## Anti-patterns
- Centering a glyph inside a "hover background" that's larger than
the natural icon size, then surrounding the whole thing with a
padded parent — the icon ends up far inside the visible edge.
- Hardcoded `padding: EdgeInsets.symmetric(horizontal: 12)` on every
control regardless of whether the right edge has an action — see
"no double-edge padding".
- Tab strip inside `Column` without `crossAxisAlignment.stretch` —
the strip ends mid-pane.
- `mainAxisSize.min` on the tab strip's outer Row when you actually
want it to fill parent width — only use `min` for pill-shaped
controls that should hug their content.
- Eyeballing alignment without working back from a target margin in
pixels. The math matters; see "uniform inner spacing".
## Testing alignment
When iterating on a control's spacing:
1. State the target margin (e.g. "6px around the close icon, all
sides except left").
2. Map every contributing source: parent padding, gap SizedBoxes,
container alignment offsets, icon-to-container size differences.
3. Sum them. Adjust until they hit the target.
4. Verify visually — perceived mass may justify a 1–2px tweak.
@@ -0,0 +1,89 @@
# Icons — Phosphor + clide-owned painters
## Phosphor Icons
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
`assets/fonts/phosphor/` (regular, bold, fill weights).
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` — full
mapping of all 1512 icon codepoints to kebab-case and PascalCase
names. Read this file to look up any icon by name or codepoint.
### Adding an icon
Find the codepoint in `codepoints.csv`, then add a `static const`
entry to `PhosphorIcons` in `lib/widgets/src/icons/phosphor.dart`:
```dart
static const arrowClockwise = PhosphorIconPainter(0xe036);
```
Only add icons we actually use — don't bulk-import the full set.
### Using an icon
```dart
ClideIcon(PhosphorIcons.arrowClockwise, size: 13)
```
Or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
**Fill weight:** `family: 'Phosphor-Fill'`.
## clide-owned painters
Some shapes are simple enough to paint directly without an icon
font. Hand-rolled `ClideIconPainter` subclasses live under
`lib/widgets/src/icons/`:
- `CheckIcon`, `ChevronIcon`, `CloseIcon` (`x.dart`)
- `DotIcon`, `FolderIcon`, `GearIcon`
- `GitBranchIcon`, `PlugIcon`, `SearchIcon`
- `TerminalIcon`, `WarningIcon`
Use these for tiny, theme-aware glyphs (close ×, dropdown chevrons,
status dots) where pulling in the Phosphor font weight would be
overkill or where the visual needs to match the theme's stroke
weight conventions.
Pattern for a new painter:
```dart
class FoobarIcon extends ClideIconPainter {
const FoobarIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..strokeWidth = 0.10
..strokeCap = StrokeCap.round;
// Coordinates are 0..1 (the painter is given a unit square).
canvas.drawLine(const Offset(0.2, 0.2), const Offset(0.8, 0.8), p);
}
}
```
## Sizing
Icon sizes used in clide (subject to consolidation under
`ClideSpacing` — see T-86):
- `10` — micro: close × inside a tab
- `13` — caption-row icons (sidebar, status bar)
- `14` — standard inline icons (icon rail)
- `16` — small icon hit-target outer container
- `18`–`20` — emphatic / standalone icons
Pass `size:` to `ClideIcon`; the painter receives a unit-square
canvas regardless. Color defaults to `globalForeground`; pass
explicit `color:` for muted/active variants.
## Anti-patterns
- Importing all of Phosphor — only declare codepoints we use.
- Hand-painting a glyph that already exists in Phosphor at the right
weight — use the font.
- Hardcoded `Color` on icons — pass through the surface tokens
(`globalForeground`, `globalTextMuted`, `panelActiveBorder`, etc.).
@@ -0,0 +1,120 @@
# Surface — token selection per surface type
Pick tokens based on **where** the widget lives, not what it does.
## Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
```
background → chromeBackground
text → chromeForeground
border → chromeBorder (1px)
active text → globalForeground
```
## Side panels (sidebar, context panel)
```
background → chromeBackground (both sides — they're chrome frame)
text → sidebarForeground
hover → sidebarItemHover
selected → sidebarItemSelected
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
```
Padding: 2px on outer edges, 0px on divider edge.
## Center column (workspace, Claude pane, editor)
```
background → panelBackground
text → globalForeground
```
No padding — content fills edge to edge.
## Pane headers (`ClidePaneChrome`)
```
background → panelHeader
text (title) → panelHeaderForeground
text (sub) → globalTextMuted
```
## Tabs (`MultitabPane`, `ClideTabBar`)
```
strip bg → tabBarBackground
strip border → bottom: dividerColor (anchors strip to body)
active fg → tabActiveForeground
inactive fg → tabInactiveForeground
active bg → panelHeader (elevated chrome)
inactive bg → tabBarBackground (blends with strip)
active border→ panelActiveBorder (top accent, 1.5px)
side border → panelBorder
```
For control geometry inside tabs (close button placement, padding,
two-column title+action layout) see [`geometry.md`](geometry.md).
## List items (decisions, tickets, file rows, backlinks)
```
background → (none / transparent)
hover bg → listItemHoverBackground
selected bg → listItemSelectedBackground
text → listItemForeground / sidebarForeground (in sidebar)
selected txt → listItemSelectedForeground
```
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
## Buttons
```
normal → buttonBackground / buttonForeground / buttonBorder
hover → buttonHoverBackground
active → buttonActiveBackground
primary → buttonActiveBackground bg + globalBackground text
subtle → listItemBackground / listItemHoverBackground (no border)
```
## Dividers and separators
```
line → dividerColor (always, everywhere)
drag handle → 8px hit area, 1px visible line, panel bg fill
hover line → panelActiveBorder
```
## Status indicators
```
success/ok → statusSuccess (green: done, added, connected)
warning → statusWarning (amber: question, modified, missing)
error → statusError (red: deleted, rejected, cancelled)
info → statusInfo (blue: in_progress, modified)
```
Map semantic states, not visual styles:
- `done` / `added` / `ok` → `statusSuccess`
- `in_progress` / `modified` → `statusInfo`
- `question` / `warning` → `statusWarning`
- `cancelled` / `deleted` / `error` → `statusError`
## Overlays (dialogs, palette, tooltips)
```
dialog bg → modalSurfaceBackground
dialog border→ modalSurfaceBorder
backdrop → modalOverlayBackground
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
```
## Anti-patterns
- `globalBackground` for panel fill → use `panelBackground`
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
- Tab active border = `globalFocus` → use `panelActiveBorder`
@@ -0,0 +1,70 @@
# Theme — token system, palette, typography
## Token identity rule
Every visual surface gets its own named token. Never borrow a token from
another surface just because they happen to resolve to the same color.
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
**Right:** Create `chromeBackground` that resolves to the same palette key.
When two surfaces share a color:
1. **Same conceptual surface** (sidebar + context panel are both "side
panels") → one shared token set is fine.
2. **Different surfaces that happen to match** (hat bar + sidebar +
status bar are all "chrome frame") → create a shared primitive in the
palette/semantic layer (e.g. `bgChrome`) and give each surface its
own token that maps to that primitive. This lets themes diverge them
later without breaking widgets.
## Palette depth primitives
The palette layer has these depth primitives (defined in each theme YAML):
- `bg` (`#20202C`) — outermost root, behind everything
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
- `surface` (`#242838`) — elevated: pane headers, active tabs
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
Chrome tokens (`chromeBackground` / `chromeForeground` / `chromeBorder`)
are the shared root for all frame surfaces. They resolve to `bgSunken` /
`textDim` / `border` in the palette. Themes can override them to diverge
hat from sidebar from status bar if desired.
## Typography
Three constants — never hardcode sizes or families:
```
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
family mono → clideMonoFamily (JetBrainsMono)
body size → clideFontBody (15)
caption size → clideFontCaption (14) — status bar, section headers, git info
mono size → clideFontMono (14) — terminal, code, paths, IDs
```
Use `ClideText` for themed text. Set `muted: true` for secondary text
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
## Extension-owned domain colors
Extensions that need domain-specific color coding (ticket types, decision
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
1. Create a color map class in the extension (e.g. `TicketTypeColors`).
2. Ship dark and light presets, auto-selected via
`ClideTheme.of(context).dark`.
3. Store user overrides under `ext.<id>.colors` in settings.
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`.
This keeps the core token surface lean and lets each extension own its
palette. The pattern scales to any extension needing domain colors.
## Where to look in the codebase
- Full token list: `lib/kernel/src/theme/tokens.dart`
- Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
- Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
- Decision: D-43 (handoff), D-44 (four bundled themes), D-45 (syntax tokens)
+240
View File
@@ -0,0 +1,240 @@
---
name: whats-next
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
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.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Agent, AskUserQuestion
---
# What's Next
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.
## Step 0: Sync state
Decisions on disk may be ahead of pql.db. Always sync before reading:
```bash
pql decisions sync
```
If `pql` is missing, stop and tell the user — don't fall back to grep.
---
## Step 1: Batch Selection
### 1a. Find active top-level work
Pql has no `milestone` concept; **initiatives** (and large **epics**) play
that role. List in-flight top-level work:
```bash
pql ticket list --status in_progress --pretty
pql ticket list --status ready --pretty
```
If nothing is `in_progress` or `ready` at the initiative/epic level,
fall back to `pql plan status --pretty` for a dashboard read and ask the
user which area to advance.
### 1b. Build the work landscape
For each candidate epic or initiative, expand its children:
```bash
pql ticket show <id> --with-children --pretty
```
Collect every leaf ticket (story/task/bug) underneath. Deduplicate.
### 1c. Filter to unblocked tickets
For each leaf with status `ready` or `backlog`, check blockers:
```bash
pql ticket show <id> --with-blockers --pretty
```
A ticket is **unblocked** if every blocker is `done` or `cancelled`.
Drop the rest.
### 1d. Rank and group
Rank unblocked tickets by:
1. **Priority** (critical > high > medium > low) — read from ticket fields.
2. **Epic proximity to done** — for each epic parent, compute
`done_children / total_children`. Higher ratio ranks higher: finishing
an epic unlocks downstream work and tightens the board.
3. **Fan-out** — tickets that unblock the most other tickets rank higher.
Approximate by scanning `pql ticket list --status backlog --pretty`
and counting how many list this ticket in their blockers (use
`--with-blockers` per candidate, or read `--jsonl` once and reduce in
memory).
Group into **epic-sized batches**: tickets sharing a `parent_id`, or a
logical cluster if no shared parent. If nothing groups naturally, batch
by area (the directory the work touches, e.g. `lib/src/pty/`).
### 1e. Show the board
```bash
pql ticket board --pretty
```
This is the "what's currently in flight" view — the user wants to see
WIP before committing to more.
### 1f. Present the recommended batch
Show the user:
- The recommended batch — IDs, titles, priorities, parent epic.
- **Why this batch** — which epic it advances, what it unblocks downstream.
- Current board state (WIP count vs. ready/backlog).
- One or two alternative batches worth considering.
Wait for user confirmation before Step 2.
---
## Step 2: Ticket Refinement Review
Two cases — handle the cheap one first.
### 2a. Tickets with empty descriptions → use pql
If any ticket in the batch has no description, hand off to pql's
built-in refinement flow:
```bash
pql ticket refine list --pretty
pql ticket refine next --pretty # full context for the next one
pql ticket refine write T-NN '{"description":"..."}'
```
Walk these with the user (AskUserQuestion per ticket if appropriate)
before moving on.
### 2b. Tickets with descriptions → spawn refinement agents
For each ticket that already has a description but may still be
under-specified, spawn one agent in parallel. Use `general-purpose`
subagent_type (custom subagent_types lose SendMessage):
```
Agent({
subagent_type: "general-purpose",
model: "sonnet",
description: "Refine T-NN context",
prompt: "You are the Refinement Manager for ticket T-NN.
Ticket: <title>
Description: <body>
Decision ref: <D-NN or Q-NN, if set>
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.
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,
own the rendering stack, etc.) — flag tickets that conflict.
Assess: does an implementer have enough context to proceed without
guessing? Report exactly one of:
- READY: <one-paragraph summary of what the implementer needs to know>
- GAPS: <list of specific ambiguities, each with 2–3 options>"
})
```
Run all agents in parallel (single message, multiple Agent tool calls).
### 2c. Resolve gaps
For each ticket that came back GAPS, surface ambiguities to the user
via AskUserQuestion. After the user resolves, append the resolution to
the ticket via pql:
```bash
pql ticket refine write T-NN '{"description":"<existing body>\n\n---\nRefinement: <resolution>"}'
```
If a gap really requires a new D-record (architectural choice, not just
detail), flag it. Ask whether to write the D-record now (`pql decisions
claim D <domain> "title"` then author the markdown) or defer with a note
on the ticket.
### 2d. Present refined batch summary
Per ticket:
- READY summary, or the resolution the user just gave.
- Linked D/Q-records.
- Remaining blockers (should be none — re-check if Step 1 was a while ago).
Ask: "Batch ready. Activate?"
---
## Step 3: Batch Activation
### 3a. Mark tickets in_progress
Batch transition (comma-separated IDs):
```bash
pql ticket status T-1,T-2,T-3 in_progress
```
### 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.
### 3c. Spawn implementation agents (optional)
If the user wants agents driving the work, spawn `general-purpose`
subagents (`model: sonnet`) per ticket. Each prompt should include:
- Ticket details + the refinement summary from Step 2.
- The full content of any linked D-record (Read it and inline it — don't
just cite the ID; the agent has no project memory of it).
- Repo guardrails the work touches (from CLAUDE.md — quote the relevant
bullets, don't link).
- A RULES block: write files only, no git commits, no destructive ops,
message back when blocked or done.
### 3d. Report
End with a tight summary:
- Branch.
- Tickets now `in_progress`.
- Agents spawned (if any).
- Next step: implement, then commit per the [git-commit skill](../git-commit/SKILL.md).
---
## Anti-patterns
- Don't skip Step 0 — stale `pql.db` makes the rest of the skill lie.
- 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.
+21
View File
@@ -69,3 +69,24 @@ jobs:
- run: (cd app && flutter pub get) - run: (cd app && flutter pub get)
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium) - run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
- run: ci/test_e2e.sh - run: ci/test_e2e.sh
docs:
name: dart doc (lib API)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: dart pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+9
View File
@@ -33,6 +33,9 @@ tools/ui/.serve.pid
# -- dugite-native (bundled git, downloaded at build time) --------------- # -- dugite-native (bundled git, downloaded at build time) ---------------
/native/dugite/ /native/dugite/
# -- dart doc output (generated by `dart doc`, uploaded as CI artefact) -
/doc/
# -- Test, coverage, profile output ------------------------------------ # -- Test, coverage, profile output ------------------------------------
*.test *.test
*.out *.out
@@ -95,3 +98,9 @@ legacy/**/.coverage
legacy/**/.coverage.* legacy/**/.coverage.*
.clide/settings.yaml .clide/settings.yaml
.claude/skills/pql/ .claude/skills/pql/
# frame0-wireframe local ID ↔ Frame0 ID mapping; per-machine state.
*.idmap.json
.pql/*
!.pql/pql-plan.json
!.pql/hooks/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# --- pql plan import ---
# Auto-installed by pql init. Imports planning state when the
# snapshot file changes on pull/merge. Safe no-op if pql is absent.
if command -v pql >/dev/null 2>&1; then
changed=$(git diff-tree -r --name-only ORIG_HEAD HEAD -- .pql/pql-plan.json 2>/dev/null)
if [ -n "$changed" ]; then
pql plan import 2>/dev/null
fi
fi
# --- end pql ---
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# --- pql plan export ---
# Auto-installed by pql init. Exports planning state so it's
# committed alongside code changes. Safe no-op if pql is absent.
if command -v pql >/dev/null 2>&1; then
pql plan export 2>/dev/null
if ! git diff --quiet -- .pql/pql-plan.json 2>/dev/null; then
git add .pql/pql-plan.json
fi
fi
# --- end pql ---
+937 -181
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -16,6 +16,105 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [Unreleased]
### Added
- Staged `dart doc` CI job — generates and uploads an HTML API
reference for the public `lib/` surface. The step wraps
`dart doc --validate-links` and grep-fails the build on any warning,
so broken doc refs and dangling links can't accumulate. Inert with
the rest of the workflow until Gitea Actions activates.
- Mouse wheel scrolling in Claude pane — converts scroll events to
PgUp/PgDown so Claude Code (and other TUI apps) scroll their
history naturally.
- Welcome screen Tips card — six common keybindings shown below the
START / RECENT row when the viewport is tall enough.
- `MultitabPane` widget + `MultitabController` for panes that host
N runtime tab instances of the same kind. Generic over a payload
type, supports pinned/non-closeable tabs (primary), drag-reorder,
close × on hover, and an optional `+` add button. Used by the
Claude pane to render primary + secondaries.
- `MultitabPane.keepAlive` mode — when set, all entry bodies stay
mounted via IndexedStack so switching tabs preserves their state
(PTY connections, scroll position, etc.).
### Changed
- Claude pane uses `MultitabPane` for primary + secondaries — drops
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
`clideInset*` for paddings, `clideGap*` for sibling distances,
`clideIcon*` / `clideControlHeight` for control sizes. Inline
pixel literals replaced where they were repeated.
### Changed
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner).
### Fixed
- Closing a secondary Claude pane tab now kills its tmux session
on the clide socket, honouring D-41's "closing a secondary kills
that tmux session" lifecycle. Previously `pane.close` only killed
the ptyc-spawned tmux client and the server-side session leaked.
- Cold-start reap: every clide launch kills any leftover secondary
tmux sessions for the current repo before spawning new ones, so
D-41's "secondary numbering resets between runs" holds even after
an abrupt previous exit (kill -9, crash, force-quit).
- `claude.kill-all-sessions` command now actually kills the
server-side tmux sessions for the repo, not just the panes.
- Terminal cell grid no longer drifts on bold text — bold rendering
is suppressed at the painter level since synthetic bold (with no
Bold.ttf registered) shifts glyph advance widths.
- PTY surfaces errno on `forkpty`, `write`, and `ioctl` failures
instead of swallowing them. `execve` failures in the spawned
child now write a diagnostic line to the slave PTY before
`_exit`, so the parent's reader sees the cause instead of an
immediate EOF that looked indistinguishable from clean exit.
PTY `write` loops on short writes; both `NativePty.write` and
`PtySession.write` now throw `PtyException` on hard errors.
- PTY teardown order fixed — kill the child first so the master
fd returns EOF, await the reader isolate exit, then close the
fd. Previously closing the fd while the isolate still polled it
could briefly target a reused fd. Reader isolate spawn errors
in both `NativePty` and `PtySession` are now surfaced via the
output stream instead of silently dropped. `_recvFdAsync` no
longer leaks the `ReceivePort` when `Isolate.spawn` throws, and
`PtySession.spawn` closes the master fd if any post-receive
step fails.
- IPC server hardening: per-request 60s timeout (configurable),
broadcast/response write failures logged instead of swallowed,
client dropped on response-write failure, and the stale-socket
retry now probes for a live daemon before unlinking the socket
(refusing to start if one answers).
- `pane.spawn` and `editor.open` now map POSIX errno values to
actionable IPC error kinds. ENOENT → `not_found`, EACCES/EPERM
→ `user_error` with a permissions hint, EISDIR/ENOTDIR/EEXIST
→ distinct user-error/conflict, EMFILE/ENFILE → `tool_error`
with a "fd limit hit" hint. Previously every spawn/open failure
was an indistinguishable `tool_error`.
### Security
- `files.read` and `files.ls` now reject paths that resolve outside
the workspace root. Previously a relative path containing `..`
could read arbitrary files via path traversal.
### Changed
- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
pub.dev dependency with owned code under `lib/src/terminal/`. Drops
three transitive dependencies (xterm, quiver, zmodem).
- Bundle clide-specific tmux.conf for Claude pane sessions: no status
bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
- Claude pane spawns `claude` directly inside tmux with
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode
(input box pinned at the bottom).
- PTY read buffer increased from 4KB to 64KB.
- Terminal view 2px padding on all sides.
- Remove bold JetBrains Mono font registration to prevent glyph width
mismatch in terminal rendering.
## [2.0.0] — 2026-05-03 ## [2.0.0] — 2026-05-03
### Fixed ### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What clide is ## What clide is
A Flutter desktop IDE for Claude Code. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach. An IDE for Claude Code CLI. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach.
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). - **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56).
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it. - **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
+3 -2
View File
@@ -167,11 +167,11 @@ ifeq ($(FLUTTER_OS),linux)
done done
@mkdir -p $(HOME)/.local/share/applications @mkdir -p $(HOME)/.local/share/applications
@sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \ @sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \
> $(HOME)/.local/share/applications/clide.desktop > $(HOME)/.local/share/applications/net.schweitz.clide.desktop
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true @gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true @update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
@echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide" @echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide"
@echo "desktop: ~/.local/share/applications/clide.desktop" @echo "desktop: ~/.local/share/applications/net.schweitz.clide.desktop"
@echo "version: $(VERSION)" @echo "version: $(VERSION)"
else ifeq ($(FLUTTER_OS),macos) else ifeq ($(FLUTTER_OS),macos)
@mkdir -p $(HOME)/Applications @mkdir -p $(HOME)/Applications
@@ -189,6 +189,7 @@ uninstall: ## Remove installed clide.
ifeq ($(FLUTTER_OS),linux) ifeq ($(FLUTTER_OS),linux)
rm -f $(INSTALL_DIR)/clide rm -f $(INSTALL_DIR)/clide
rm -rf $(INSTALL_PREFIX)/clide rm -rf $(INSTALL_PREFIX)/clide
rm -f $(HOME)/.local/share/applications/net.schweitz.clide.desktop
rm -f $(HOME)/.local/share/applications/clide.desktop rm -f $(HOME)/.local/share/applications/clide.desktop
@for size in $(ICON_SIZES); do \ @for size in $(ICON_SIZES); do \
rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \ rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \
+4 -4
View File
@@ -1,6 +1,6 @@
# clide # clide
A Flutter desktop IDE for Claude Code. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS. An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
## Architecture ## Architecture
@@ -39,10 +39,10 @@ make push-check # pre-push gate: decisions + core + fast tests
## Status ## Status
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](legacy/). Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Architectural decisions: [`decisions/`](decisions/). Design doc: [`docs/initial-plan.md`](https://github.com/postmeridiem/clide/blob/main/docs/initial-plan.md). Architectural decisions: [`decisions/`](https://github.com/postmeridiem/clide/tree/main/decisions).
## License ## License
MIT. See [`LICENSE`](LICENSE). MIT. See [`LICENSE`](https://github.com/postmeridiem/clide/blob/main/LICENSE).
+35
View File
@@ -0,0 +1,35 @@
# clide tmux.conf — loaded via tmux -f for every Claude pane session.
# Tuned for embedding inside xterm.dart; no status bar, large
# scrollback, mouse-scroll passthrough, zero escape delay.
# No status bar — clide renders its own pane chrome.
set -g status off
# 50k lines of scrollback (tmux default is 2000).
set -g history-limit 50000
# Zero escape delay — xterm.dart delivers escape sequences
# atomically, so the 500ms default just adds latency.
set -sg escape-time 0
# Mouse on — scroll wheel events reach tmux's copy-mode so the
# user can scroll back through Claude output.
set -g mouse on
# 256color + true-color passthrough.
set -g default-terminal "xterm-256color"
set -ga terminal-overrides ",xterm-256color:Tc"
# Don't ring the bell visually or audibly — clide owns notifications.
set -g visual-bell off
set -g bell-action none
# Keep the session alive when the shell exits — clide manages
# lifecycle via pane.close, not tmux session destruction.
set -g remain-on-exit off
# Allow alt-screen passthrough for full-screen programs.
set -g alternate-screen on
# Focus events let the terminal's focus tracking work through tmux.
set -g focus-events on
+10 -9
View File
@@ -53,7 +53,7 @@ dependencies:
purpose: >- purpose: >-
Monospace face for terminal panes, diff views, code editors, and Monospace face for terminal panes, diff views, code editors, and
any other monospace surface. any other monospace surface.
weights_bundled: [Regular, Italic, Bold, BoldItalic] weights_bundled: [Regular, Italic]
- name: Josefin Sans - name: Josefin Sans
kind: font kind: font
@@ -87,17 +87,18 @@ dependencies:
YAML parser for theme files and extension manifests. Justified YAML parser for theme files and extension manifests. Justified
exception to prefer-zero-deps; Dart-team maintained. exception to prefer-zero-deps; Dart-team maintained.
- name: xterm - name: terminal (based on xterm.dart)
kind: dart-package kind: inlined-source
version: "4.0.0" version: "4.0.0"
homepage: https://pub.dev/packages/xterm homepage: https://github.com/TerminalStudio/xterm.dart
license: MIT license: MIT
license_file: lib/src/terminal/LICENSE
purpose: >- purpose: >-
Flutter-native terminal emulator (ANSI / xterm / truecolor Terminal emulator (ANSI / xterm / truecolor parser + renderer).
parser + renderer). Powers every pane that renders a PTY — Inlined from xterm.dart v4.0.0 by xuty (MIT) and modified:
general terminal, Claude, diff views running shell commands. Scrollable removed, quiver dependency replaced, zmodem/debugger
Writing a vt100 / ANSI parser is weeks of work for no fidelity stripped, scroll forwarding rewritten. Original copyright and
gain. MIT license preserved in lib/src/terminal/LICENSE.
- name: ffi - name: ffi
kind: dart-package kind: dart-package
+1 -1
View File
@@ -89,7 +89,7 @@ Future<void> main(List<String> argv) async {
void _printHelp(IOSink sink) { void _printHelp(IOSink sink) {
sink.writeln(''' sink.writeln('''
clide $clideVersion — Flutter desktop IDE for Claude Code. clide $clideVersion — IDE for Claude Code CLI.
Usage: Usage:
clide --daemon Run the long-running daemon process. clide --daemon Run the long-running daemon process.
+1 -1
View File
@@ -8,7 +8,7 @@ A11y + i18n are Tier-0 contracts, not Tier-6 polish.
- **Date:** 2026-04-21 - **Date:** 2026-04-21
- **Decision:** Every widget primitive wraps its interaction surface in a `Semantics` node at the point of creation. A11y coverage is a test-time gate (`ci/test_a11y.sh`), not a post-hoc polish pass. `ensureSemantics()` fires at app boot; Flutter's semantics tree is always populated. - **Decision:** Every widget primitive wraps its interaction surface in a `Semantics` node at the point of creation. A11y coverage is a test-time gate (`ci/test_a11y.sh`), not a post-hoc polish pass. `ensureSemantics()` fires at app boot; Flutter's semantics tree is always populated.
- **Rationale:** Retrofitting a11y onto a grown UI is what every project that skips this promises to do later and then doesn't. Making it a Tier-0 contract costs one `Semantics` line per primitive and a semantic-coverage test; postponing costs a rewrite. - **Rationale:** Retrofitting a11y onto a grown UI is what every project that skips this promises to do later and then doesn't. Making it a Tier-0 contract costs one `Semantics` line per primitive and a semantic-coverage test; postponing costs a rewrite.
- **Cost:** Widget authors maintain correct labels; tests reject new primitives without semantics. Enforced by `app/test/a11y/` coverage tests. - **Cost:** Widget authors maintain correct labels; tests reject new primitives without semantics. Enforced by `test/a11y/` coverage tests.
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
### D-21: i18n is a Tier-0 contract (fframe pattern + locale-fallback chain) ### D-21: i18n is a Tier-0 contract (fframe pattern + locale-fallback chain)
+10 -2
View File
@@ -13,7 +13,7 @@ Core, rendering, IPC, kernel, panel manager.
### D-8: Feature-first folder layout ### D-8: Feature-first folder layout
- **Date:** 2026-04-21 - **Date:** 2026-04-21
- **Decision:** Under `app/lib/`, organise by feature (`kernel/`, `extension/`, `widgets/`, `builtin/<name>/`) rather than by layer (`models/`, `views/`, `controllers/`). Private implementation lives under each feature's `src/`; the feature's public surface is a barrel file at the feature root (e.g. `app/lib/kernel/kernel.dart`). - **Decision:** Under `lib/`, organise by feature (`kernel/`, `extension/`, `widgets/`, `builtin/<name>/`) rather than by layer (`models/`, `views/`, `controllers/`). Private implementation lives under each feature's `src/`; the feature's public surface is a barrel file at the feature root (e.g. `lib/kernel/kernel.dart`).
- **Rationale:** Features grow and get deleted as units; layer-first layouts fragment a feature across three directories and make deletions risky. Matches extensions-as-features (every extension already has its own folder). - **Rationale:** Features grow and get deleted as units; layer-first layouts fragment a feature across three directories and make deletions risky. Matches extensions-as-features (every extension already has its own folder).
- **Cost:** Imports cross features only via the barrel — enforce by review, no automated check yet. - **Cost:** Imports cross features only via the barrel — enforce by review, no automated check yet.
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
@@ -89,7 +89,7 @@ Core, rendering, IPC, kernel, panel manager.
### D-4: Ignore file strategy ### D-4: Ignore file strategy
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage) - **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain. - **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept. - **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface. - **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
- **Cost:** Removing clide from a repo leaves pql working with vanilla defaults (clide's last-written `ignore_files:` stays until pql or the user rewrites it; worth reconsidering during uninstall design). - **Cost:** Removing clide from a repo leaves pql working with vanilla defaults (clide's last-written `ignore_files:` stays until pql or the user rewrites it; worth reconsidering during uninstall design).
@@ -235,4 +235,12 @@ Core, rendering, IPC, kernel, panel manager.
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout) (center hat always visible), [D-51](#d-51-panel-collapse-12px-spine-with-badge) (spine-cap behavior). - **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout) (center hat always visible), [D-51](#d-51-panel-collapse-12px-spine-with-badge) (spine-cap behavior).
- **Raised by:** 2026-04-23 interaction model refinement. - **Raised by:** 2026-04-23 interaction model refinement.
### D-64: No telemetry — architectural commitment
- **Date:** 2026-05-03
- **Decision:** clide does not phone home. No analytics SDKs (Firebase, Sentry, Mixpanel, hand-rolled). No crash reporters that upload automatically — crashes produce local logs the user can read and optionally attach to a manual bug report. No auto-update checks without user action. No license validation calls. No feature flags fetched from a server. No A/B testing, experiments, remote config, or "anonymous usage statistics." This is not a "default off" setting; it is an architectural commitment. Proposals to add telemetry under any framing — opt-in, anonymized, debug-only, "just errors" — are out of scope for this project, full stop.
- **Rationale:** clide is a space to think, not a surface for data collection. Users installing clide are choosing a tool that does not watch them. That promise is worth more than any data we could collect. The architectural commitment is the feature.
- **Cost:** No usage data for product decisions; no automated crash triage. Accepted — user trust is the product decision.
- **Cross-reference:** [D-60](tooling.md#d-60-no-network-on-default-launch-path), `POLICY.md`.
- **Raised by:** 2026-05-03 policy-to-decision migration (T-28).
--- ---
+3 -3
View File
@@ -13,7 +13,7 @@ Extension contract, Lua runtime, grain, contribution points.
### D-16: Built-ins in Dart, third-party in sandboxed Lua ### D-16: Built-ins in Dart, third-party in sandboxed Lua
- **Date:** 2026-04-21 - **Date:** 2026-04-21
- **Decision:** Bundled extensions (every `app/lib/builtin/<name>`) are Dart — they link into the app binary. Third-party extensions (Tier 6) run in sandboxed Lua via the `ptyc`-peer Lua runtime (see [D-19](#d-19-lua-runtime-as-ptyc-peer-supporter-tool)). The contribution contract is language-agnostic — same contribution shapes, same manifest schema. - **Decision:** Bundled extensions (every `lib/builtin/<name>`) are Dart — they link into the app binary. Third-party extensions (Tier 6) run in sandboxed Lua via the `ptyc`-peer Lua runtime (see [D-19](#d-19-lua-runtime-as-ptyc-peer-supporter-tool)). The contribution contract is language-agnostic — same contribution shapes, same manifest schema.
- **Rationale:** Dart built-ins get full SDK power (custom painters, isolates, FFI); third-party Lua gets a narrow capability API, no arbitrary syscalls, no deps on pub.dev. VS Code's Node-runs-with-full-power model is a supply-chain nightmare we're explicitly rejecting. - **Rationale:** Dart built-ins get full SDK power (custom painters, isolates, FFI); third-party Lua gets a narrow capability API, no arbitrary syscalls, no deps on pub.dev. VS Code's Node-runs-with-full-power model is a supply-chain nightmare we're explicitly rejecting.
- **Cost:** Two implementation paths for the same contract; we pay in API design to keep them equivalent at the seams. - **Cost:** Two implementation paths for the same contract; we pay in API design to keep them equivalent at the seams.
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
@@ -41,7 +41,7 @@ Extension contract, Lua runtime, grain, contribution points.
### D-46: Core frame builtins vs shipped extensions boundary ### D-46: Core frame builtins vs shipped extensions boundary
- **Date:** 2026-04-22 - **Date:** 2026-04-22
- **Decision:** The `app/lib/builtin/` directory is reserved for core frame infrastructure — components the shell cannot function without. Everything that renders *content* (editor surfaces, tool panels, integrations) is a shipped extension: still Dart, still bundled in the binary, but architecturally an extension that registers through the contribution contract and could in principle be disabled by the user. - **Decision:** The `lib/builtin/` directory is reserved for core frame infrastructure — components the shell cannot function without. Everything that renders *content* (editor surfaces, tool panels, integrations) is a shipped extension: still Dart, still bundled in the binary, but architecturally an extension that registers through the contribution contract and could in principle be disabled by the user.
**Core frame builtins** (cannot be disabled; the frame breaks without them): **Core frame builtins** (cannot be disabled; the frame breaks without them):
`default-layout`, `welcome`, `ipc-status`, `theme-picker`, `terminal`, `files`, `grammars-core`, `settings-ui`, `extensions-ui`, `keybindings-ui`. `default-layout`, `welcome`, `ipc-status`, `theme-picker`, `terminal`, `files`, `grammars-core`, `settings-ui`, `extensions-ui`, `keybindings-ui`.
@@ -53,7 +53,7 @@ Extension contract, Lua runtime, grain, contribution points.
`editor`, `claude`, `claude-control`, `markdown`, `diff`, `git-ui`, `pql`, `canvas`, `graph`, `decisions`, `tickets`, `todos`, `problems`. `editor`, `claude`, `claude-control`, `markdown`, `diff`, `git-ui`, `pql`, `canvas`, `graph`, `decisions`, `tickets`, `todos`, `problems`.
- **Rationale:** The previous session bled several content extensions (jira, todos, decisions, tickets, canvas, graph) into `builtin/` as stubs, treating "shipped with the app" as "part of the frame." This conflates two concerns: the frame's structural integrity and the bundled feature set. A user who disables the canvas extension should get a working IDE with no canvas panel; a user who disables the layout extension gets a broken window. The boundary is: can the frame render and function without it? If yes, it's a shipped extension, not a frame builtin. - **Rationale:** The previous session bled several content extensions (jira, todos, decisions, tickets, canvas, graph) into `builtin/` as stubs, treating "shipped with the app" as "part of the frame." This conflates two concerns: the frame's structural integrity and the bundled feature set. A user who disables the canvas extension should get a working IDE with no canvas panel; a user who disables the layout extension gets a broken window. The boundary is: can the frame render and function without it? If yes, it's a shipped extension, not a frame builtin.
- **Cost:** Shipped extensions need a separate registration path (e.g. `app/lib/extensions/` or equivalent) distinct from `app/lib/builtin/`. The extension contract must support "bundled Dart extension" as a first-class category alongside "builtin" and "third-party Lua." Migration is incremental — move one at a time, each behind a working build. - **Cost:** Shipped extensions need a separate registration path (e.g. `lib/extensions/` or equivalent) distinct from `lib/builtin/`. The extension contract must support "bundled Dart extension" as a first-class category alongside "builtin" and "third-party Lua." Migration is incremental — move one at a time, each behind a working build.
- **Supersedes:** Removes `builtin.jira` (already deleted; should never have been a builtin — Jira integration is a third-party extension, not a shipped one). - **Supersedes:** Removes `builtin.jira` (already deleted; should never have been a builtin — Jira integration is a third-party extension, not a shipped one).
- **Raised by:** 2026-04-22 session review. - **Raised by:** 2026-04-22 session review.
+1 -1
View File
@@ -43,7 +43,7 @@ Q&D record system itself, kanban, commit conventions, changelog.
- **Date:** 2026-04-21 - **Date:** 2026-04-21
- **Decision:** Planning subcommands (`decisions`, `ticket`, `plan`) land in pql's repo long-term. Clide consumes them via shell-out, matching [D-3](architecture.md)'s wrap-don't-duplicate rule for pql. Clide does not grow Dart subcommands for planning. - **Decision:** Planning subcommands (`decisions`, `ticket`, `plan`) land in pql's repo long-term. Clide consumes them via shell-out, matching [D-3](architecture.md)'s wrap-don't-duplicate rule for pql. Clide does not grow Dart subcommands for planning.
- **Rationale:** A terminal user or a user in VS Code / JetBrains still needs Q&D access. Binding planning tooling to clide-the-Flutter-app would cut them off from their own work — see [R-9](rejected.md#r-9-port-planning-tooling-into-clide). pql is already the CLI, already universal, already wrapped by clide. - **Rationale:** A terminal user or a user in VS Code / JetBrains still needs Q&D access. Binding planning tooling to clide-the-Flutter-app would cut them off from their own work — see [R-9](rejected.md#r-9-port-planning-tooling-into-clide). pql is already the CLI, already universal, already wrapped by clide.
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-40](#d-40-python-stopgap-under-toolsscriptsplan). Gated by [Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate). - **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-40](#d-40-superseded-python-stopgap-under-toolsscriptsplan). Gated by [Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate).
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
### D-40: [SUPERSEDED] Python stopgap under `tools/scripts/plan` ### D-40: [SUPERSEDED] Python stopgap under `tools/scripts/plan`
+1 -1
View File
@@ -16,7 +16,7 @@ Tooling-domain questions currently live here too. Split into
### Q-16: `tree-sitter-dart` grammar maintenance ### Q-16: `tree-sitter-dart` grammar maintenance
- **Status:** Open - **Status:** Open
- **Question:** `UserNobody14/tree-sitter-dart` is archived. `nielsenko/tree-sitter-dart` is the maintained fork. Do we pin `nielsenko/`, mirror it in-repo, or lean on the Dart analyzer's own semantic output and skip tree-sitter for Dart? - **Question:** `UserNobody14/tree-sitter-dart` is archived. `nielsenko/tree-sitter-dart` is the maintained fork. Do we pin `nielsenko/`, mirror it in-repo, or lean on the Dart analyzer's own semantic output and skip tree-sitter for Dart?
- **Context:** If tree-sitter is the Tier-2 answer ([Q-15](#q-15-editor-tab-full-lsp-vs-tree-sitter-only)), grammar sourcing matters. - **Context:** If tree-sitter is the Tier-2 answer ([Q-15](#q-15-editor-tab-full-lsp-vs-tree-sitter-only-highlight)), grammar sourcing matters.
- **Source:** 2026-04-21 planning. - **Source:** 2026-04-21 planning.
### Q-17: Icon set growth ### Q-17: Icon set growth
+1 -1
View File
@@ -41,7 +41,7 @@ Test pyramid, drivers, client-side constraint.
### D-28: Test organisation — mirror `lib/` in `test/` ### D-28: Test organisation — mirror `lib/` in `test/`
- **Date:** 2026-04-21 - **Date:** 2026-04-21
- **Decision:** Every test file lives at the same relative path as its subject. `app/lib/kernel/src/i18n/catalog_loader.dart` pairs with `app/test/kernel/i18n/catalog_loader_test.dart`. No separate `unit/` vs `widget/` directories; test type is detected by what the test imports. - **Decision:** Every test file lives at the same relative path as its subject. `lib/kernel/src/i18n/catalog_loader.dart` pairs with `test/kernel/i18n/catalog_loader_test.dart`. No separate `unit/` vs `widget/` directories; test type is detected by what the test imports.
- **Rationale:** Matching paths makes "jump to test" predictable in any editor. Type-by-imports matches how `flutter test` already works. - **Rationale:** Matching paths makes "jump to test" predictable in any editor. Type-by-imports matches how `flutter test` already works.
- **Cost:** Large feature folders mirror into large test folders. Acceptable. - **Cost:** Large feature folders mirror into large test folders. Acceptable.
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
+42 -2
View File
@@ -20,7 +20,7 @@ Toolchain, supply chain, CI, ignore strategy.
### D-42: Dependencies documented in `licenses.yaml` ### D-42: Dependencies documented in `licenses.yaml`
- **Date:** 2026-04-22 - **Date:** 2026-04-22
- **Decision:** `app/assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section. - **Decision:** `assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
- **Rationale:** Complements [D-31](#d-31-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files. - **Rationale:** Complements [D-31](#d-31-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
- **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list. - **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list.
- **Raised by:** 2026-04-22 planning (user-directed best practice). - **Raised by:** 2026-04-22 planning (user-directed best practice).
@@ -47,8 +47,48 @@ Toolchain, supply chain, CI, ignore strategy.
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH. - **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason. - **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26. - **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates. GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code. - **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates (tracked in T-88). GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml). - **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
- **Raised by:** 2026-04-25 macOS sandbox investigation. - **Raised by:** 2026-04-25 macOS sandbox investigation.
### D-60: No network on default launch path
- **Date:** 2026-04-26
- **Decision:** clide does not perform network I/O during app startup, library initialization, or first use of any API unless the user has explicitly taken an action whose stated purpose is to cause a network fetch. Opening the app, opening a file, or typing in a buffer are not such actions. Libraries that download native binaries on first import (the `wasm_run` pattern), auto-installing language servers/grammars, CDN-fetched assets, startup telemetry, and unsolicited update checks are all prohibited. Signed, pinned fetches are permitted only when: the URL is hardcoded in the repo, the artifact is verified against a committed hash or signature, the fetch is cached, failure produces a clear error, and the primary function works without the fetch succeeding. If all five cannot be satisfied, vendor the artifact or require explicit user action.
- **Rationale:** clide's security model claims that app behavior on a user's machine is fully determined by the signed release artifact and the repository state at build time. The moment something is fetched from the network that wasn't audited at build time, the entire sandboxing and trust story collapses. See `POLICY.md` §"The core rule."
- **Cost:** Some features require vendoring artifacts that other apps would download at first launch. Accepted — the trust boundary is worth the extra build complexity.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-63](#d-63-vendored-binary-rebuild-process), `POLICY.md`.
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
### D-61: Dependency vetting checklist
- **Date:** 2026-04-26
- **Decision:** Before adding any dependency (direct or transitive), verify: (1) **Network behavior** — no network I/O during import, init, or first call; no postinstall scripts that download binaries; check transitive deps with `flutter pub deps`. (2) **Binary provenance** — native binaries must be built from source in the same repo, not fetched from release artifacts. (3) **Maintainership** — single-maintainer packages need explicit sign-off and a documented fallback; packages with no activity in 12+ months require a controlled fork or inlining. (4) **Surface area** — prefer packages that do one thing; a dep adding 15 transitive deps for a 100-line problem should be inlined. (5) **Version pinning** — exact-pinned per D-31, lockfile committed, CVE-checked, source-reviewed, justified in place. (6) **License** — compatible per D-65.
- **Rationale:** D-31 states the budget; this record codifies the gate each dependency must pass. The checklist exists so agents and human contributors apply the same standard without re-deriving it each time.
- **Cost:** Longer evaluation cycle for new dependencies. Intentional — the cost of a bad dep is higher.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-60](#d-60-no-network-on-default-launch-path), [D-65](#d-65-license-compatibility-matrix), `POLICY.md`.
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
### D-62: Dependency removal process
- **Date:** 2026-04-26
- **Decision:** A dependency is not removed until all five steps are completed in a single PR: (1) Grep the entire repository for references to the package, its exports, and contributed type names — zero hits outside git history. (2) Regenerate the lockfile. (3) Update `assets/licenses.yaml` to drop the package and any orphaned transitive deps. (4) Remove any vendored artifacts (binaries, prebuilt assets, generated bindings) and delete their `BUILD.md` records. (5) Check for architectural assumptions the dep was carrying — if it justified a data flow, build step, or platform strategy, the replacement must pick up those responsibilities or the relevant D-record must be updated.
- **Rationale:** "I deleted the line from pubspec.yaml" is the start of a removal, not the end. Partial removals leave orphaned lockfile entries (installed on fresh clones), stale license entries, or orphaned vendored binaries that look legitimate.
- **Cost:** Removal PRs are larger than the one-line diff suggests. Accepted.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), `POLICY.md`.
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
### D-63: Vendored binary rebuild process
- **Date:** 2026-04-26
- **Decision:** Every vendored native binary has a `BUILD.md` next to it recording: (1) exact upstream source (git URL + commit SHA, not a version tag), (2) full build command with all compile flags, (3) toolchain version (compiler, linker, target triple), (4) expected output size and SHA-256 hash, (5) any patches applied (stored as `.patch` files in the same directory). Rebuilds happen in CI, not on contributor machines. The rebuild PR updates `BUILD.md`, the binaries, and hashes atomically. No binary is committed without a reproducibility record. Security patches to vendored deps are tracked with the same urgency as source-level vulnerabilities. Dropping a platform requires a policy decision; adding one requires adding it to the CI matrix and rebuilding all vendored binaries first.
- **Rationale:** Vendored binaries are inside the trust boundary — the signed release contains exactly these bytes. Without reproducibility records, a committed binary is unverifiable and therefore untrustworthy.
- **Cost:** Rebuilds require CI infrastructure and cross-compilation. Currently partially manual (T-25 tracks full CI automation).
- **Cross-reference:** [D-60](#d-60-no-network-on-default-launch-path), [D-42](#d-42-dependencies-documented-in-licensesyaml), T-25, `POLICY.md`.
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
### D-65: License compatibility matrix
- **Date:** 2026-04-26
- **Decision:** clide is MIT-licensed. Every dependency, vendored binary, bundled font, and asset must be compatible and attributed. **Compatible (permissive):** MIT, Apache-2.0, BSD-2/3, ISC, Zlib, Unlicense, CC0. **Compatible with care (copyleft):** MPL-2.0 for libraries; LGPL only for dynamically-linked vendored binaries where users can replace the library. **Not compatible:** GPL for linked code (GPL vendored binaries like git are fine — they ship as separate executables), AGPL, SSPL, "commercial use prohibited," unreviewed custom licenses. Apache-2.0 deps preserve their NOTICE file verbatim. Apache-2.0-with-LLVM-exception requires the exception text specifically. Fonts and icon sets are attributed even if the license doesn't strictly require it. An incompatible or unclear license is disqualifying regardless of technical merit.
- **Rationale:** The compatibility rules existed in POLICY.md but were not captured as a D-record, making them invisible to the decision-reference system. This record makes them queryable and cross-referenceable.
- **Cost:** License evaluation adds time to the vetting checklist. Intentional.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-61](#d-61-dependency-vetting-checklist), `POLICY.md`.
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
--- ---
@@ -0,0 +1,155 @@
# PTY + IPC error-handling audit
Date: 2026-05-05
Ticket: T-18
Decision ref: D-5
Punch list of error-handling issues in `lib/src/pty/`, `lib/src/ipc/`,
and `lib/src/daemon/`. Severity-ranked. Each item references the
follow-up ticket where the fix lands.
## Critical — silent failures, leaks, races
1. **`lib/src/pty/native_pty.dart:155-158`** — `forkpty()` failure
throws `StateError('forkpty() failed')` with no errno. Caller
can't distinguish ENOMEM/EAGAIN/ENOENT-of-/dev/ptmx. Capture
errno before `_freeAll` (which may trample it) and surface via
`PtyException`. → T-75
2. **`lib/src/pty/native_pty.dart:160-165`** — Child process: `chdir`
and `execve` returns are ignored. If `execve` returns (i.e.
fails), we fall through to `_exit(1)` with no diagnostic. Write
a one-line error envelope to fd 1 before exiting so the parent's
reader sees "exec failed: ENOENT" instead of immediate EOF. → T-75
3. **`lib/src/pty/native_pty.dart:244-251`** — `write()` ignores
`_nativeWrite` return. Short writes silently drop bytes; -1/EPIPE
reported as successful "wrote -1". Loop until full length is
written or surface errno on negative returns. → T-75
4. **`lib/src/pty/native_pty.dart:259-262`** — `resize()` ignores
`_ioctl` and `_nativeKill` return values. EBADF on a half-closed
fd silently no-ops. Set `_dead = true` on EBADF. → T-75
5. **`lib/src/pty/native_pty.dart:198-210`** — Race: `_spawnReader`
is `async` but `NativePty.start` returns immediately. `close()`
racing with isolate spawn can leave the isolate orphaned. Make
`start` await reader spawn or track the spawn-future. → T-76
6. **`lib/src/pty/native_pty.dart:280-290`** — `close()` sets
`_dead = true` *before* `_nativeClose(_fd)`, but the reader
isolate continues polling on `_fd`. If a new fd reuses that
number, the reader's `poll` may briefly target the wrong file.
Send shutdown signal via SendPort or self-pipe before closing. → T-76
7. **`lib/src/pty/session.dart:135-153`** — Resource leak: if
`_recvFdAsync`, `setWinsize`, `proc.stdout.first.timeout`, or
`_extractPid` throws, the spawned ptyc Process and (in some
cases) the received `masterFd` leak. Only line 151 closes
`masterFd`. Wrap post-spawn block in try/catch that kills `proc`,
closes `masterFd`, and rethrows. → T-76
8. **`lib/src/pty/session.dart:240`** — `_recvFdAsync`: if
`Isolate.spawn` itself throws, `port` is leaked. Wrap in
try/catch. → T-76
9. **`lib/src/pty/session.dart:165-176`** — `write()` returns raw
`libc.write` result without checking < 0 / errno or looping for
short writes. Same as #3. → T-75
10. **`lib/src/pty/session.dart:271-275`** — `Isolate.spawn(...).then(...)`
is fire-and-forget. If spawn fails, the error is silently
swallowed and `_readerIsolate` remains null forever. Add
`.catchError` or await it. → T-76
11. **`lib/src/ipc/server.dart:30-39`** — `broadcast()` `try/catch (_)`
swallows write errors with no logging. At least log the kind. → T-77
12. **`lib/src/ipc/server.dart:107`** — `client.writeln(resp.encode())`
is not awaited and not guarded. If client disconnected mid-dispatch,
this throws asynchronously with no `onError` handler. Wrap in
try/catch and remove the client from `_clients`. → T-77
13. **`lib/src/ipc/server.dart:83-108`** — `_handleLine` runs
`await dispatch(msg)` with no per-request timeout. A misbehaving
handler blocks the connection's read pipeline indefinitely. → T-77
14. **`lib/src/ipc/server.dart:46-50`** — Stale-socket retry deletes
the socket file unconditionally on `SocketException`. If two
daemon instances race to start, the second rips the first's live
socket out from under it. Try `connect()` first; refuse if a
live daemon answers. → T-77
## High — degraded UX / debugging
15. **`lib/src/daemon/pane_commands.dart:87-96`** — `_spawn`
catch-all flattens every failure into `tool_error: pane.spawn
failed: <toString>`. "binary not found", "permission denied",
"out of pty fds" all look the same. Map `PtyException.errno`
(ENOENT/EACCES/EMFILE) to distinct hints/codes. → T-79
16. **`lib/src/daemon/editor_commands.dart:67-76`** — Same pattern;
`editor.open` catch-all loses FileSystemException distinctions
(ENOENT vs EACCES vs EISDIR). → T-79
17. **`lib/src/daemon/files_commands.dart:78`** — `file.readAsStringSync()`
is unguarded; UTF-8 errors, permission errors, races with deletion
turn into a 500-style dispatch error instead of a clean
`IpcResponse.err`. Wrap in try/catch. → T-81
18. **`lib/src/daemon/files_commands.dart:74`** — Path is concatenated
with `/` and never validated. `path: "../../../etc/passwd"`
traverses out of `files.root`. Resolve and verify the resulting
path stays under `root.absolute.path`. → T-78 (security)
19. **`lib/src/pty/session.dart:201-234`** — `close()` distinguishes
EOF/EBADF/EIO only in comments. The 500ms timeout is silent
(`onTimeout: () {}`). Log the timeout so we know when SIGKILL
was actually needed. → T-81
20. **`lib/src/pty/session.dart:390-394`** — Reader isolate treats
any negative read return that isn't EINTR as EOF — including
transient EAGAIN or recoverable EIO. Inspect errno and log
non-EBADF/EIO/0 cases. → T-81
21. **`lib/src/pty/ffi/scm_rights.dart:115-116`** — Returned cmsg-data
fd is read without sanity-checking against `msgControllen`. A
malformed peer that sends only a partial cmsg could let us read
garbage as an fd. Verify `dataOffset + 4 <= msgControllen`
before deref. → T-81
22. **`lib/src/ipc/server.dart:41-56`** — `start()` logs to
`stderr.writeln` but the rest of the daemon uses no logger. In
the Flutter-host process stderr is often consumed by the engine.
Standardize on a logger. → T-80
## Medium — cleanliness
23. **`lib/src/pty/session.dart:390`, `native_pty.dart:262, 285`** —
Magic errno/signal numbers (`4=EINTR`, `9=SIGKILL`, `28=SIGWINCH`,
`_kSighup=1`). Pull into named constants. → T-80
24. **`lib/src/pty/ffi/libc.dart:232-245`** — `errno` getter does a
`lookupFunction` on every access (catching ArgumentError every
call on macOS). Cache the resolved function pointer. → T-80
25. **`lib/src/daemon/git_commands.dart:283`** — `_gitError` always
reports `tool_error`. A `git push` rejection or merge conflict is
user-actionable, not a tool failure; could map to
`IpcExitCode.conflict` when stderr matches known patterns. → T-81
26. **`lib/src/ipc/server.dart:97`** — Dispatch error shows
`dispatch failed: $e` (full exception toString). Trim and add
the request `cmd` for log correlation. → T-80
27. **`lib/src/daemon/pane_commands.dart:136`** — `registry.write(id, bytes)`
return value `n` is shown to caller, but if `n == -1` (write failed)
we still respond `ok`. Distinguish. → T-81
28. **`lib/src/ipc/envelope.dart:88-94`** — `IpcResponse.fromJson`
throws `TypeError` if `ok=false` but `error` is missing. No
graceful degradation for a malformed peer response. → T-81
29. **`lib/src/pty/native_pty.dart:111-119`** — PATH resolution
silently uses the first existing match without checking `X_OK`.
A non-executable file shadows a valid binary further along PATH. → T-81
+22 -1
View File
@@ -1,4 +1,25 @@
# clide · design handoff # clide · design handoff (superseded reference)
> **Status (2026-05-06):** Reference-only. The implementation has
> moved past these mockups. The canonical wireframe set now lives at
> [`docs/wireframes/`](../wireframes/), generated from the actual
> implementation via the `frame0-wireframe` skill.
>
> Update wireframes there, not here.
>
> **Why kept:** the design tokens under `tokens/` and `themes/` still
> feed the runtime themes (per [D-43](../../decisions/architecture.md#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper)
> / [D-44](../../decisions/architecture.md#d-44-four-bundled-themes-clide-midnight-paper-terminal)).
> The HTMLs and PNGs are kept for historical context.
>
> **What changed since:** welcome screen has logo-with-wordmark and a
> Tips card spanning both columns; status line with theme switcher
> lives at the bottom right; Claude pane runs in fullscreen mode
> (`CLAUDE_CODE_NO_FLICKER=1`) so the input box is pinned by Claude
> Code itself; tmux uses an isolated `-L clide` socket with bundled
> config; sidebar layout follows D-47's "Claude is home" model.
---
Bundle for importing into the clide repo and driving further work with Claude Code. Bundle for importing into the clide repo and driving further work with Claude Code.
+182
View File
@@ -0,0 +1,182 @@
# Multitab pane — design
Ticket: T-83
Drives: T-24 (secondary Claude pane UI wiring)
Date: 2026-05-06
## Problem
Some panes need to host multiple, dynamically-spawned views of the
same kind. The first concrete case is the Claude pane: per
[D-41](../../decisions/architecture.md#d-41-claude-panes-one-primary-per-repo-tmux-backed),
each repo has exactly one **primary** Claude pane plus zero or more
**secondary** panes spawned at runtime. The user needs a way to:
- See which Claude sessions are open
- Switch between them
- Spawn a new secondary
- Close a secondary (primary has no close affordance)
The kernel's existing `TabContribution` system addresses a different
need — it lets extensions statically declare which widget shows up in
which **panel slot** (sidebar, workspace, context). It does not
support dynamic tab instances *within* a single contribution.
This design fills that gap with a reusable widget, so future panes
that need the same shape (potentially the editor — see D-48 — or
diff/preview surfaces) can adopt it without reinventing tab strips.
## Non-goals
- Replacing `TabContribution`. Slot-host tabs are static-by-design;
this widget is for inside-a-tab dynamism.
- Window-level tab management (browser-style "tear off into a window").
- Editor multi-buffer tabs. [D-48](../../decisions/architecture.md#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)
rejected those; revisiting is a separate decision.
## API sketch
```dart
class MultitabPane<T> extends StatefulWidget {
const MultitabPane({
required this.controller,
required this.tabBuilder,
required this.bodyBuilder,
this.onCloseRequested,
this.onAddRequested,
this.allowReorder = true,
});
final MultitabController<T> controller;
final Widget Function(BuildContext, MultitabEntry<T>) tabBuilder;
final Widget Function(BuildContext, MultitabEntry<T>) bodyBuilder;
final void Function(MultitabEntry<T> entry)? onCloseRequested;
final void Function()? onAddRequested;
final bool allowReorder;
}
class MultitabEntry<T> {
final String id; // stable identity (e.g. "claude.primary")
final String title; // display label
final bool closeable; // primary tabs set this false
final bool reorderable; // primary often pinned to position 0
final T payload; // domain object the bodyBuilder renders
}
class MultitabController<T> extends ChangeNotifier {
List<MultitabEntry<T>> get entries;
MultitabEntry<T>? get active;
void add(MultitabEntry<T> entry, {bool activate = true});
void remove(String id);
void activate(String id);
void reorder(String id, int newIndex);
}
```
The widget is a thin shell:
- Renders the tab strip via `ClideTabBar` (or a reorderable variant)
- Calls `bodyBuilder(active)` for the visible content
- Routes user gestures to controller methods or callbacks
- Emits `onCloseRequested` / `onAddRequested` so the host decides
the actual lifecycle (e.g. Claude pane spawns a new tmux session,
doesn't just append a UI tab)
The host owns the controller and the payload type. The widget never
touches PTY, IPC, or Claude session naming.
## Rendering
The tab strip lives at the top of the pane chrome. Layout:
```
┌──────────────────────────────────────────────────────┐
│ [primary] [secondary 1] [secondary 2] [+] │
├──────────────────────────────────────────────────────┤
│ │
│ active tab body │
│ │
└──────────────────────────────────────────────────────┘
```
- Active tab: filled background, bright text
- Inactive: muted background, muted text
- Close glyph (×) appears on hover for `closeable` tabs
- `+` button at the end if `onAddRequested` is set
- Drag-to-reorder respects `reorderable`; non-reorderable tabs
(primary) are pinned to position 0 and other tabs cannot be
dropped before them
## Interaction
- **Click a tab** → activate
- **Click ×** → call `onCloseRequested(entry)`; host decides whether
to confirm, kill the underlying session, etc.
- **Drag-and-drop** → call `controller.reorder(id, newIndex)` after
the gesture completes; controller enforces pinned positions
- **Click +** → call `onAddRequested()`; host creates the new entry
and adds it via `controller.add(...)`
- **Keyboard**: `⌘1`–`⌘9` jump to tab N; `⌘W` close active (skipped
for non-closeable); `⌘⇧[` / `⌘⇧]` cycle prev/next
## Persistence
Out of scope for the widget. Hosts that want to persist tab order or
which tabs were open across sessions read/write through their own
settings layer and seed the controller on init.
## Claude pane integration (T-24)
```
ClaudePane (host)
└── MultitabPane<ClaudeSessionRef>(
controller: claudeTabsController,
tabBuilder: (ctx, e) => Text(e.title),
bodyBuilder: (ctx, e) => ClaudePaneBody(session: e.payload),
onAddRequested: () => kernel.claude.spawnSecondary(),
onCloseRequested: (e) => kernel.claude.closeSecondary(e.payload),
)
```
`ClaudeSessionRef` carries the tmux session name + isPrimary. The
controller is seeded with `[primary]` on boot; secondaries get
appended as the user clicks `+`. Closing a secondary triggers
`pane.close` IPC and removes the entry; closing the primary is not
exposed (`closeable: false`).
## What ships in this ticket
T-83 delivers:
1. `MultitabPane` widget + `MultitabController` + `MultitabEntry`
under `lib/widgets/src/`
2. Unit tests for controller invariants (pinned positions, active
selection survives close, reorder bounds)
3. Widget tests for the strip (selection, close hover, add button,
drag-reorder)
4. This design doc
T-24 picks up after and wires the Claude pane to it.
## Open questions
- **Q: Where does keyboard handling live?** Host or widget?
Recommendation: widget owns `⌘W` / `⌘1`–`⌘9` / cycle; host wires
them via the existing kernel commands surface. Avoids each host
reinventing the same shortcuts.
**Nesting caveat:** the widget composes (a Claude tab can host
its own `MultitabPane<EditorBuffer>` etc.). Shortcut handling
must be scoped to the focus subtree, not registered globally —
otherwise the outermost pane consumes `⌘W` even when the user
is typing in a nested tab. Implementation: wrap shortcuts in a
`Shortcuts` / `Actions` widget inside the pane's `Focus` scope
so the innermost focused pane wins via Flutter's normal
shortcut-resolution chain.
- **Q: Tab overflow** when many secondaries open? Recommendation:
start with horizontal scroll; revisit if it becomes a problem.
- **Q: Tab-strip visual style** — match `ClideTabBar` exactly, or
introduce a denser variant for inside-pane use? Recommendation:
reuse `ClideTabBar` initially; spin off a `ClideTabBar.dense`
variant only if visual hierarchy issues emerge.
+166
View File
@@ -0,0 +1,166 @@
title: MultitabPane — architecture {
near: top-center
shape: text
style.font-size: 24
style.bold: true
}
direction: down
host: ClaudePane (host) {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#7c5cff"
style.font-color: "#e8ecf2"
state: ChangeNotifier — owns lifecycle {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
widget: MultitabPane<T> (widget) {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#e8ecf2"
shell: builds tabstrip + body shell {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
controller: MultitabController<T> {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#e8ecf2"
api: |md
add(entry)
remove(id)
activate(id)
reorder(id, idx)
| {
style.font-color: "#a0a8b8"
}
}
entries: List<MultitabEntry<T>> {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
primary: primary {
shape: rectangle
style.fill: "#1a1f28"
style.stroke: "#7c5cff"
style.font-color: "#e8ecf2"
closeable\: false: { shape: text; style.font-color: "#7a8294"; style.font-size: 10 }
reorderable\: false: { shape: text; style.font-color: "#7a8294"; style.font-size: 10 }
}
sec1: secondary 1 {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
sec2: secondary 2 {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
ipc: kernel.claude / IPC {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#5a8c5a"
style.font-color: "#e8ecf2"
spawn: spawnSecondary() {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
close: closeSecondary(ref) {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
host -> controller: owns {
style.stroke: "#7a8294"
style.font-color: "#7a8294"
}
host -> widget: builds with {
style.stroke: "#7a8294"
style.font-color: "#7a8294"
}
controller -> entries: holds {
style.stroke: "#7a8294"
style.font-color: "#7a8294"
}
widget -> controller: subscribes (Listenable) {
style.stroke: "#7c5cff"
style.font-color: "#a0a8b8"
}
widget -> host: onAddRequested() {
style.stroke: "#d97757"
style.font-color: "#d97757"
}
widget -> host: onCloseRequested(entry) {
style.stroke: "#d97757"
style.font-color: "#d97757"
}
host -> ipc: routes user intent {
style.stroke: "#5a8c5a"
style.font-color: "#5a8c5a"
}
ipc.spawn -> entries.sec2: appends new entry {
style.stroke: "#5a8c5a"
style.font-color: "#5a8c5a"
}
ipc.close -> entries.sec1: removes entry {
style.stroke: "#d97757"
style.font-color: "#d97757"
}
note: |md
### Boundary
**Widget** is generic. It knows
about `MultitabEntry<T>` and routes
user gestures back to the host. It
never touches PTY, IPC, or session
naming.
**Host** owns the controller and
decides what `add` / `close` mean
in the domain — for Claude that's
spawning/killing tmux sessions
via IPC.
This boundary is what makes the
widget reusable: any pane that
needs N runtime instances can
drop it in with their own host
and payload type.
| {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

+53
View File
@@ -0,0 +1,53 @@
# clide wireframes
Canonical layout reference, generated from the actual implementation
via the `frame0-wireframe` skill. Each `.json` is the source of
truth; the `.png` is rendered from it.
These supersede the hi-fi mockups under
[`../claude-design/`](../claude-design/), which are kept for
historical context and design tokens.
## Set
### Welcome
- [`welcome/welcome-screen.json`](welcome/welcome-screen.json) /
[.png](welcome/welcome-screen.png)
— first-run landing: logo + wordmark, START / RECENT columns,
Tips card, status line.
### Main view
- [`main/main-view.json`](main/main-view.json) /
[.png](main/main-view.png)
— three-column default: tickets sidebar, Claude pane, empty
context panel.
- [`main/editor-above-claude.json`](main/editor-above-claude.json) /
[.png](main/editor-above-claude.png)
— D-49 editor mode: editor above Claude in the middle column,
divider between, prompt Y stays fixed.
- [`main/focus-mode.json`](main/focus-mode.json) /
[.png](main/focus-mode.png)
— D-52 focus mode: full-window Claude pane, sidebars hidden,
Esc-to-exit hint in the title bar.
- [`main/sidebar-collapsed.json`](main/sidebar-collapsed.json) /
[.png](main/sidebar-collapsed.png)
— D-51 12px spine: sidebar collapsed to a vertical strip with
rotated label and activity badge.
- [`main/ticket-detail.json`](main/ticket-detail.json) /
[.png](main/ticket-detail.png)
— context panel showing a selected ticket with metadata and
description.
## Updating
1. Edit the `.json` (source of truth).
2. Re-export with the `frame0-wireframe` skill:
```
.claude/skills/frame0-wireframe/scripts/frame0-sync.py \
export docs/wireframes/<dir>/<name>.json \
docs/wireframes/<dir>/<name>.png
```
3. Commit both files.
Frame0 must be running locally for export. Don't pull from Frame0 —
the JSON is authoritative.
+209
View File
@@ -0,0 +1,209 @@
{
"name": "Claude Pane — multitab",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1100, "height": 720,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"pane-header": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1100, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"pane-title": {
"type": "Text",
"parent": "pane-header",
"left": 56, "top": 50,
"text": "claude — secondary 2",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"pane-subtitle": {
"type": "Text",
"parent": "pane-header",
"left": 56, "top": 64,
"text": "tmux · clide-claude-var-mnt-data-projects-clide-2",
"fontColor": "#7a8294",
"fontSize": 10
},
"tabstrip": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 1100, "height": 32,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"tab-primary": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 56, "top": 80, "width": 132, "height": 28,
"fillColor": "#13161c",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"tab-primary-pin": {
"type": "Text",
"parent": "tab-primary",
"left": 64, "top": 86,
"text": "📌",
"fontColor": "#7a8294",
"fontSize": 10
},
"tab-primary-text": {
"type": "Text",
"parent": "tab-primary",
"left": 84, "top": 86,
"text": "primary",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tab-sec-1": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 192, "top": 80, "width": 132, "height": 28,
"fillColor": "#13161c",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"tab-sec-1-text": {
"type": "Text",
"parent": "tab-sec-1",
"left": 204, "top": 86,
"text": "secondary 1",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tab-sec-1-close": {
"type": "Text",
"parent": "tab-sec-1",
"left": 304, "top": 86,
"text": "×",
"fontColor": "#5a6478",
"fontSize": 14
},
"tab-sec-2": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 328, "top": 80, "width": 132, "height": 28,
"fillColor": "#1a1f28",
"strokeColor": "#7c5cff",
"corners": [4, 4, 0, 0]
},
"tab-sec-2-text": {
"type": "Text",
"parent": "tab-sec-2",
"left": 340, "top": 86,
"text": "secondary 2",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"tab-sec-2-close": {
"type": "Text",
"parent": "tab-sec-2",
"left": 440, "top": 86,
"text": "×",
"fontColor": "#a0a8b8",
"fontSize": 14
},
"tab-add": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 464, "top": 80, "width": 28, "height": 28,
"fillColor": "#0e1014",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"tab-add-glyph": {
"type": "Text",
"parent": "tab-add",
"left": 474, "top": 86,
"text": "+",
"fontColor": "#7a8294",
"fontSize": 14
},
"active-divider": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 108, "width": 1100, "height": 2,
"fillColor": "#7c5cff",
"strokeColor": "#7c5cff"
},
"body": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 110, "width": 1100, "height": 650,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"body-banner-name": {
"type": "Text",
"parent": "body",
"left": 56, "top": 132,
"text": "Claude Code v2.1.128",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"body-banner-meta": {
"type": "Text",
"parent": "body",
"left": 56, "top": 148,
"text": "Opus 4.7 (1M context) · fresh secondary session",
"fontColor": "#7a8294",
"fontSize": 11
},
"body-msg-prompt": {
"type": "Text",
"parent": "body",
"left": 56, "top": 200,
"text": "› dig into the failing test in test/pty/session_test.dart",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"body-msg-resp": {
"type": "Text",
"parent": "body",
"left": 56, "top": 226,
"text": "● Looking at the write-keystrokes test. The shell process\n starts but the echo doesn't appear in the output stream.\n Let me trace the write path…",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"prompt-divider": {
"type": "Rectangle",
"parent": "body",
"left": 40, "top": 700, "width": 1100, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"prompt": {
"type": "Text",
"parent": "body",
"left": 56, "top": 712,
"text": "› Try \"run the test in this pane\"",
"fontColor": "#5a6478",
"fontSize": 12
},
"anno": {
"type": "Text",
"parent": "canvas",
"left": 720, "top": 80,
"text": "active tab gets accent border + bottom rule",
"fontColor": "#5a6478",
"fontSize": 10
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,231 @@
{
"name": "Main View — editor above Claude",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1400, "height": 860,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"title-bar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1400, "height": 36,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"title-bar-text": {
"type": "Text",
"parent": "title-bar",
"left": 720, "top": 50,
"text": "clide › clide ⌄ · src/welcome/welcome_view.dart ●",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"sidebar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 280, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"sidebar-section": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 96,
"text": "▾ FILES",
"fontColor": "#7a8294",
"fontSize": 11
},
"files-tree": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 124,
"text": "▸ assets\n▸ bin\n▾ lib\n ▾ builtin\n ▾ welcome\n ▾ src\n welcome_view.dart\n extension.dart\n ▸ kernel\n ▸ widgets",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"sidebar-rail": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 822, "width": 280, "height": 40,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"editor-pane": {
"type": "Rectangle",
"parent": "canvas",
"left": 320, "top": 76, "width": 740, "height": 320,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"editor-header": {
"type": "Rectangle",
"parent": "editor-pane",
"left": 320, "top": 76, "width": 740, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"editor-title": {
"type": "Text",
"parent": "editor-header",
"left": 336, "top": 86,
"text": "✎ welcome_view.dart ●",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"editor-demote": {
"type": "Text",
"parent": "editor-header",
"left": 1024, "top": 86,
"text": "👁 ⌘W",
"fontColor": "#7a8294",
"fontSize": 11
},
"editor-line-1": {
"type": "Text",
"parent": "editor-pane",
"left": 336, "top": 130,
"text": " 1 class _TipsCard extends StatelessWidget {",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"editor-line-2": {
"type": "Text",
"parent": "editor-pane",
"left": 336, "top": 148,
"text": " 2 const _TipsCard({required this.tokens});",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"editor-line-3": {
"type": "Text",
"parent": "editor-pane",
"left": 336, "top": 166,
"text": " 3 final SurfaceTokens tokens;",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"editor-line-4": {
"type": "Text",
"parent": "editor-pane",
"left": 336, "top": 184,
"text": " 4",
"fontColor": "#5a6478",
"fontSize": 11
},
"editor-line-5": {
"type": "Text",
"parent": "editor-pane",
"left": 336, "top": 202,
"text": " 5 static const _tips = <(String, String)>[",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"editor-line-6": {
"type": "Text",
"parent": "editor-pane",
"left": 336, "top": 220,
"text": " 6 ('Quick open', '⌘P'),",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"divider": {
"type": "Rectangle",
"parent": "canvas",
"left": 320, "top": 396, "width": 740, "height": 4,
"fillColor": "#262a32",
"strokeColor": "#262a32"
},
"claude-pane": {
"type": "Rectangle",
"parent": "canvas",
"left": 320, "top": 400, "width": 740, "height": 462,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"claude-header": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 320, "top": 400, "width": 740, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"claude-title": {
"type": "Text",
"parent": "claude-header",
"left": 336, "top": 410,
"text": "claude — primary",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-conv": {
"type": "Text",
"parent": "claude-pane",
"left": 336, "top": 460,
"text": "› refactor _TipsCard into its own file\n\n● Moved _TipsCard to lib/builtin/welcome/src/tips_card.dart.\n Re-exported from welcome_view.dart for backwards compat.\n Updated test/builtin/welcome/widget_test.dart to import\n the new path.",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-prompt-divider": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 320, "top": 800, "width": 740, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"claude-prompt": {
"type": "Text",
"parent": "claude-pane",
"left": 336, "top": 808,
"text": "› Try \"run the tests\"",
"fontColor": "#5a6478",
"fontSize": 12
},
"context-panel": {
"type": "Rectangle",
"parent": "canvas",
"left": 1060, "top": 76, "width": 380, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"context-section": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 96,
"text": "▾ PREVIEW",
"fontColor": "#7a8294",
"fontSize": 11
},
"context-doc": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 134,
"text": "Welcome screen\n\nThe first surface a user sees when no project is\nopen, or after closing one.\n\nLayout: centered max-850px column with logo +\nwordmark, START / RECENT row, and (when the\nviewport is tall enough) a TIPS card spanning\nthe full width.",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"status-branch": {
"type": "Text",
"parent": "canvas",
"left": 56, "top": 880,
"text": "⑂ main ↑5 · 3 modified",
"fontColor": "#7a8294",
"fontSize": 11
},
"status-app": {
"type": "Text",
"parent": "canvas",
"left": 700, "top": 880,
"text": "● application ok",
"fontColor": "#5a8c5a",
"fontSize": 11
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+131
View File
@@ -0,0 +1,131 @@
{
"name": "Main View — focus mode",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1400, "height": 860,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"title-bar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1400, "height": 36,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"title-bar-text": {
"type": "Text",
"parent": "title-bar",
"left": 720, "top": 50,
"text": "clide › clide ⌄ — focus mode",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"esc-hint": {
"type": "Text",
"parent": "title-bar",
"left": 1340, "top": 50,
"text": "Esc to exit",
"fontColor": "#7a8294",
"fontSize": 11
},
"claude-pane": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 1400, "height": 786,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"claude-header": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 40, "top": 76, "width": 1400, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"claude-title": {
"type": "Text",
"parent": "claude-header",
"left": 56, "top": 86,
"text": "claude — primary",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-subtitle": {
"type": "Text",
"parent": "claude-header",
"left": 56, "top": 100,
"text": "tmux · clide-claude-var-mnt-data-projects-clide · focus",
"fontColor": "#7a8294",
"fontSize": 10
},
"claude-banner-icon": {
"type": "Text",
"parent": "claude-pane",
"left": 56, "top": 142,
"text": "▰▰",
"fontColor": "#d97757",
"fontSize": 12
},
"claude-banner-name": {
"type": "Text",
"parent": "claude-pane",
"left": 82, "top": 142,
"text": "Claude Code v2.1.128",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-banner-meta": {
"type": "Text",
"parent": "claude-pane",
"left": 82, "top": 158,
"text": "Opus 4.7 (1M context) with high effort · Claude Max",
"fontColor": "#7a8294",
"fontSize": 11
},
"claude-msg-1-prompt": {
"type": "Text",
"parent": "claude-pane",
"left": 56, "top": 230,
"text": "› switch to focus mode for the next push",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"claude-msg-1-resp": {
"type": "Text",
"parent": "claude-pane",
"left": 56, "top": 258,
"text": "● Focus mode entered. Esc to exit. Sidebar and context\n panel are hidden — Claude pane fills the workspace.",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-prompt-divider": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 40, "top": 800, "width": 1400, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"claude-prompt-caret": {
"type": "Text",
"parent": "claude-pane",
"left": 56, "top": 808,
"text": "›",
"fontColor": "#7a8294",
"fontSize": 16
},
"claude-prompt-placeholder": {
"type": "Text",
"parent": "claude-pane",
"left": 80, "top": 810,
"text": "Try \"go full screen\"",
"fontColor": "#5a6478",
"fontSize": 12
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+447
View File
@@ -0,0 +1,447 @@
{
"name": "Main View — project loaded",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1400, "height": 860,
"fillColor": "#0e1014",
"strokeColor": "#1c2028",
"corners": [4, 4, 4, 4]
},
"title-bar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1400, "height": 36,
"fillColor": "#1c2028",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"title-bar-text": {
"type": "Text",
"parent": "title-bar",
"left": 720, "top": 50,
"text": "clide › clide ⌄",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"sidebar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 360, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"sidebar-search": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 92, "width": 280, "height": 32,
"fillColor": "#0e1014",
"strokeColor": "#262a32",
"corners": [4, 4, 4, 4]
},
"sidebar-search-icon": {
"type": "Text",
"parent": "sidebar-search",
"left": 68, "top": 100,
"text": "⌕",
"fontColor": "#5a6478",
"fontSize": 14
},
"section-in-progress": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 142,
"text": "▾ IN PROGRESS · 1",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-1": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 166, "width": 328, "height": 64,
"fillColor": "#0e1014",
"strokeColor": "#262a32",
"corners": [4, 4, 4, 4]
},
"ticket-1-id": {
"type": "Text",
"parent": "ticket-1",
"left": 70, "top": 178,
"text": "● T-21 ← T-4",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-1-title": {
"type": "Text",
"parent": "ticket-1",
"left": 70, "top": 200,
"text": "implement welcome screen per hi-fi design",
"fontColor": "#e8ecf2",
"fontSize": 13
},
"section-ready": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 250,
"text": "▾ READY · 3",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-2": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 274, "width": 328, "height": 56,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"ticket-2-id": {
"type": "Text",
"parent": "ticket-2",
"left": 70, "top": 282,
"text": "● T-17 ← T-8",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-2-title": {
"type": "Text",
"parent": "ticket-2",
"left": 70, "top": 302,
"text": "add dart doc generation to CI",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"ticket-3": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 332, "width": 328, "height": 56,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"ticket-3-id": {
"type": "Text",
"parent": "ticket-3",
"left": 70, "top": 340,
"text": "● T-24 ← T-3",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-3-title": {
"type": "Text",
"parent": "ticket-3",
"left": 70, "top": 360,
"text": "secondary Claude pane UI wiring",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"section-backlog": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 408,
"text": "▾ BACKLOG · 33",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-4": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 432, "width": 328, "height": 50,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"ticket-4-id": {
"type": "Text",
"parent": "ticket-4",
"left": 70, "top": 440,
"text": "● T-7 ← T-7",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-4-title": {
"type": "Text",
"parent": "ticket-4",
"left": 70, "top": 458,
"text": "Tier 5 — canvas and graph view",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"ticket-5": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 484, "width": 328, "height": 50,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"ticket-5-id": {
"type": "Text",
"parent": "ticket-5",
"left": 70, "top": 492,
"text": "● T-23 ← T-4",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-5-title": {
"type": "Text",
"parent": "ticket-5",
"left": 70, "top": 510,
"text": "wire command palette keybinding",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"sidebar-rail": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 822, "width": 360, "height": 40,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"rail-icon-1": {
"type": "Text",
"parent": "sidebar-rail",
"left": 68, "top": 832,
"text": "▤",
"fontColor": "#e8ecf2",
"fontSize": 16
},
"rail-icon-2": {
"type": "Text",
"parent": "sidebar-rail",
"left": 110, "top": 832,
"text": "◇",
"fontColor": "#7a8294",
"fontSize": 16
},
"rail-icon-3": {
"type": "Text",
"parent": "sidebar-rail",
"left": 152, "top": 832,
"text": "▢",
"fontColor": "#7a8294",
"fontSize": 16
},
"rail-icon-4": {
"type": "Text",
"parent": "sidebar-rail",
"left": 194, "top": 832,
"text": "⑂",
"fontColor": "#7a8294",
"fontSize": 16
},
"rail-icon-5": {
"type": "Text",
"parent": "sidebar-rail",
"left": 236, "top": 832,
"text": "⌕",
"fontColor": "#7a8294",
"fontSize": 16
},
"rail-icon-6": {
"type": "Text",
"parent": "sidebar-rail",
"left": 278, "top": 832,
"text": "!",
"fontColor": "#7a8294",
"fontSize": 16
},
"claude-pane": {
"type": "Rectangle",
"parent": "canvas",
"left": 400, "top": 76, "width": 660, "height": 786,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"claude-header": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 400, "top": 76, "width": 660, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"claude-title": {
"type": "Text",
"parent": "claude-header",
"left": 416, "top": 86,
"text": "claude — primary",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-subtitle": {
"type": "Text",
"parent": "claude-header",
"left": 416, "top": 100,
"text": "tmux · clide-claude-var-mnt-data-projects-clide",
"fontColor": "#7a8294",
"fontSize": 10
},
"claude-logo-icon": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 142,
"text": "▰▰",
"fontColor": "#d97757",
"fontSize": 12
},
"claude-banner-name": {
"type": "Text",
"parent": "claude-pane",
"left": 442, "top": 142,
"text": "Claude Code v2.1.128",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-banner-model": {
"type": "Text",
"parent": "claude-pane",
"left": 442, "top": 158,
"text": "Opus 4.7 (1M context) with high effort · Claude Max",
"fontColor": "#7a8294",
"fontSize": 11
},
"claude-banner-cwd": {
"type": "Text",
"parent": "claude-pane",
"left": 442, "top": 174,
"text": "/var/mnt/data/projects/clide",
"fontColor": "#7a8294",
"fontSize": 11
},
"claude-message-1": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 230,
"text": "› wireframe the main view",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"claude-response-1": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 258,
"text": "● Building wireframe from current implementation. Three\n columns: sidebar (tickets shown), Claude pane, context\n panel. Let me check the tab contributions first.",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-mcp-warn": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 720,
"text": "1 MCP server failed · /mcp",
"fontColor": "#d97757",
"fontSize": 11
},
"claude-prompt-divider": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 400, "top": 752, "width": 660, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"claude-prompt-caret": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 760,
"text": "›",
"fontColor": "#7a8294",
"fontSize": 16
},
"claude-prompt-placeholder": {
"type": "Text",
"parent": "claude-pane",
"left": 440, "top": 762,
"text": "Try \"export the wireframes\"",
"fontColor": "#5a6478",
"fontSize": 12
},
"claude-prompt-meta": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 794,
"text": "[jeroenschweitzer@danoontje clide] | Opus 4.7 (1M context)",
"fontColor": "#7a8294",
"fontSize": 10
},
"context-panel": {
"type": "Rectangle",
"parent": "canvas",
"left": 1060, "top": 76, "width": 380, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"context-empty": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 96,
"text": "Select a ticket to view details.",
"fontColor": "#7a8294",
"fontSize": 12
},
"context-rail": {
"type": "Rectangle",
"parent": "canvas",
"left": 1060, "top": 822, "width": 380, "height": 40,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"context-rail-icon-1": {
"type": "Text",
"parent": "context-rail",
"left": 1300, "top": 832,
"text": "▤",
"fontColor": "#e8ecf2",
"fontSize": 16
},
"context-rail-icon-2": {
"type": "Text",
"parent": "context-rail",
"left": 1340, "top": 832,
"text": "◇",
"fontColor": "#7a8294",
"fontSize": 16
},
"context-rail-icon-3": {
"type": "Text",
"parent": "context-rail",
"left": 1380, "top": 832,
"text": "⌕",
"fontColor": "#7a8294",
"fontSize": 16
},
"context-rail-icon-4": {
"type": "Text",
"parent": "context-rail",
"left": 1416, "top": 832,
"text": "▢",
"fontColor": "#7a8294",
"fontSize": 16
},
"status-branch": {
"type": "Text",
"parent": "canvas",
"left": 56, "top": 880,
"text": "⑂ main ↑5",
"fontColor": "#7a8294",
"fontSize": 11
},
"status-app": {
"type": "Text",
"parent": "canvas",
"left": 700, "top": 880,
"text": "● application ok",
"fontColor": "#5a8c5a",
"fontSize": 11
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+155
View File
@@ -0,0 +1,155 @@
{
"name": "Main View — sidebar collapsed",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1400, "height": 860,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"title-bar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1400, "height": 36,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"title-bar-text": {
"type": "Text",
"parent": "title-bar",
"left": 720, "top": 50,
"text": "clide › clide ⌄",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"spine-left": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 12, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"spine-left-label": {
"type": "Text",
"parent": "spine-left",
"left": 44, "top": 220,
"text": "TICKETS",
"fontColor": "#7a8294",
"fontSize": 9
},
"spine-left-badge": {
"type": "Ellipse",
"parent": "spine-left",
"left": 43, "top": 96, "width": 6, "height": 6,
"fillColor": "#d97757",
"strokeColor": "#d97757"
},
"claude-pane": {
"type": "Rectangle",
"parent": "canvas",
"left": 52, "top": 76, "width": 1008, "height": 786,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"claude-header": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 52, "top": 76, "width": 1008, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"claude-title": {
"type": "Text",
"parent": "claude-header",
"left": 68, "top": 86,
"text": "claude — primary",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-subtitle": {
"type": "Text",
"parent": "claude-header",
"left": 68, "top": 100,
"text": "tmux · sidebar collapsed (⌘⇧1) — Tickets has activity",
"fontColor": "#7a8294",
"fontSize": 10
},
"claude-banner": {
"type": "Text",
"parent": "claude-pane",
"left": 68, "top": 142,
"text": "Claude Code v2.1.128 · Opus 4.7 (1M context)",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-msg": {
"type": "Text",
"parent": "claude-pane",
"left": 68, "top": 200,
"text": "› hide the sidebar, I want more room\n\n● Sidebar collapsed to a 12px spine. Activity badge\n on the spine indicates new ticket changes — click\n the spine or press ⌘⇧1 to expand.",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-prompt-divider": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 52, "top": 800, "width": 1008, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"claude-prompt": {
"type": "Text",
"parent": "claude-pane",
"left": 68, "top": 808,
"text": "› Try \"show the sidebar again\"",
"fontColor": "#5a6478",
"fontSize": 12
},
"context-panel": {
"type": "Rectangle",
"parent": "canvas",
"left": 1060, "top": 76, "width": 380, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"context-section": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 96,
"text": "▾ VIEWER",
"fontColor": "#7a8294",
"fontSize": 11
},
"context-empty": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 134,
"text": "Open a file to preview.",
"fontColor": "#7a8294",
"fontSize": 12
},
"status-branch": {
"type": "Text",
"parent": "canvas",
"left": 56, "top": 880,
"text": "⑂ main ↑5",
"fontColor": "#7a8294",
"fontSize": 11
},
"status-app": {
"type": "Text",
"parent": "canvas",
"left": 700, "top": 880,
"text": "● application ok",
"fontColor": "#5a8c5a",
"fontSize": 11
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+269
View File
@@ -0,0 +1,269 @@
{
"name": "Main View — ticket detail in context",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1400, "height": 860,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"title-bar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1400, "height": 36,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"title-bar-text": {
"type": "Text",
"parent": "title-bar",
"left": 720, "top": 50,
"text": "clide › clide ⌄",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"sidebar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 360, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"section": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 96,
"text": "▾ IN PROGRESS · 1",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-selected": {
"type": "Rectangle",
"parent": "sidebar",
"left": 56, "top": 122, "width": 328, "height": 64,
"fillColor": "#1a1f28",
"strokeColor": "#7c5cff",
"corners": [4, 4, 4, 4]
},
"ticket-selected-id": {
"type": "Text",
"parent": "ticket-selected",
"left": 70, "top": 134,
"text": "● T-24 ← T-3",
"fontColor": "#a0a8b8",
"fontSize": 11
},
"ticket-selected-title": {
"type": "Text",
"parent": "ticket-selected",
"left": 70, "top": 156,
"text": "secondary Claude pane UI wiring",
"fontColor": "#e8ecf2",
"fontSize": 13
},
"section-2": {
"type": "Text",
"parent": "sidebar",
"left": 56, "top": 210,
"text": "▾ READY · 3",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-other-1": {
"type": "Text",
"parent": "sidebar",
"left": 70, "top": 240,
"text": "● T-17 add dart doc generation to CI",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"ticket-other-2": {
"type": "Text",
"parent": "sidebar",
"left": 70, "top": 264,
"text": "● T-21 implement welcome screen…",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"claude-pane": {
"type": "Rectangle",
"parent": "canvas",
"left": 400, "top": 76, "width": 540, "height": 786,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"claude-header": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 400, "top": 76, "width": 540, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"claude-title": {
"type": "Text",
"parent": "claude-header",
"left": 416, "top": 86,
"text": "claude — primary",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-msg": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 142,
"text": "› let's pick T-24 next\n\n● T-24 selected. Detail loaded in the right panel.\n This ticket wires up the secondary Claude pane UI —\n see D-41 for the spawn/close semantics.",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"claude-prompt-divider": {
"type": "Rectangle",
"parent": "claude-pane",
"left": 400, "top": 800, "width": 540, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"claude-prompt": {
"type": "Text",
"parent": "claude-pane",
"left": 416, "top": 808,
"text": "› Try \"start it\"",
"fontColor": "#5a6478",
"fontSize": 12
},
"context-panel": {
"type": "Rectangle",
"parent": "canvas",
"left": 940, "top": 76, "width": 500, "height": 786,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"ticket-id": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 96,
"text": "T-24 · task · ready",
"fontColor": "#7a8294",
"fontSize": 11
},
"ticket-title": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 118,
"text": "secondary Claude pane UI wiring",
"fontColor": "#e8ecf2",
"fontSize": 18
},
"field-parent": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 162,
"text": "Parent",
"fontColor": "#7a8294",
"fontSize": 11
},
"field-parent-val": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 162,
"text": "T-3 — Tier 1 — Claude in xterm pane, PTY, session",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"field-decision": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 188,
"text": "Decision",
"fontColor": "#7a8294",
"fontSize": 11
},
"field-decision-val": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 188,
"text": "D-41 — Claude panes — one primary per repo, tmux-backed",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"field-priority": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 214,
"text": "Priority",
"fontColor": "#7a8294",
"fontSize": 11
},
"field-priority-val": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 214,
"text": "medium",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"field-created": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 240,
"text": "Created",
"fontColor": "#7a8294",
"fontSize": 11
},
"field-created-val": {
"type": "Text",
"parent": "context-panel",
"left": 1080, "top": 240,
"text": "2026-04-22",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"desc-label": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 296,
"text": "DESCRIPTION",
"fontColor": "#7a8294",
"fontSize": 11
},
"desc-body": {
"type": "Text",
"parent": "context-panel",
"left": 956, "top": 324,
"text": "Wire the secondary Claude pane spawn flow into the\nUI: a “new Claude session” affordance in the pane\nchrome, secondary numbering (-1, -2 …), close-to-\nprimary focus collapse, and the visual distinction\nbetween primary and secondary in the pane header.",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"actions": {
"type": "Rectangle",
"parent": "context-panel",
"left": 956, "top": 760, "width": 468, "height": 36,
"fillColor": "#0e1014",
"strokeColor": "#262a32",
"corners": [4, 4, 4, 4]
},
"action-start": {
"type": "Text",
"parent": "actions",
"left": 974, "top": 770,
"text": "▶ Start (status → in_progress)",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"status-branch": {
"type": "Text",
"parent": "canvas",
"left": 56, "top": 880,
"text": "⑂ main ↑5",
"fontColor": "#7a8294",
"fontSize": 11
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+362
View File
@@ -0,0 +1,362 @@
{
"name": "Welcome Screen",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1280, "height": 800,
"fillColor": "#0e1014",
"strokeColor": "#1c2028",
"corners": [4, 4, 4, 4]
},
"title-bar": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1280, "height": 36,
"fillColor": "#1c2028",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"title-bar-text": {
"type": "Text",
"parent": "title-bar",
"left": 660, "top": 50,
"text": "clide ⌄",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"spine-left": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 12, "height": 700,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"spine-right": {
"type": "Rectangle",
"parent": "canvas",
"left": 1308, "top": 76, "width": 12, "height": 700,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"logo": {
"type": "Text",
"parent": "canvas",
"left": 250, "top": 240,
"text": "<>",
"fontColor": "#7c8896",
"fontSize": 96
},
"logo-accent": {
"type": "Rectangle",
"parent": "canvas",
"left": 254, "top": 332, "width": 60, "height": 4,
"fillColor": "#d97757",
"strokeColor": "#d97757"
},
"wordmark": {
"type": "Text",
"parent": "canvas",
"left": 396, "top": 248,
"text": "clide",
"fontColor": "#e8ecf2",
"fontSize": 64
},
"subtitle": {
"type": "Text",
"parent": "canvas",
"left": 396, "top": 322,
"text": "IDE for Claude Code CLI",
"fontColor": "#7a8294",
"fontSize": 16
},
"start-label": {
"type": "Text",
"parent": "canvas",
"left": 250, "top": 426,
"text": "START",
"fontColor": "#7a8294",
"fontSize": 11
},
"row-open": {
"type": "Rectangle",
"parent": "canvas",
"left": 250, "top": 458, "width": 380, "height": 36,
"fillColor": "#0e1014",
"strokeColor": "#0e1014"
},
"row-open-icon": {
"type": "Text",
"parent": "row-open",
"left": 262, "top": 466,
"text": "▢",
"fontColor": "#7a8294",
"fontSize": 14
},
"row-open-label": {
"type": "Text",
"parent": "row-open",
"left": 296, "top": 468,
"text": "Open folder…",
"fontColor": "#e8ecf2",
"fontSize": 14
},
"row-open-key": {
"type": "Text",
"parent": "row-open",
"left": 590, "top": 470,
"text": "⌘O",
"fontColor": "#7a8294",
"fontSize": 12
},
"row-clone": {
"type": "Rectangle",
"parent": "canvas",
"left": 250, "top": 498, "width": 380, "height": 36,
"fillColor": "#0e1014",
"strokeColor": "#0e1014"
},
"row-clone-icon": {
"type": "Text",
"parent": "row-clone",
"left": 262, "top": 506,
"text": "⑂",
"fontColor": "#7a8294",
"fontSize": 14
},
"row-clone-label": {
"type": "Text",
"parent": "row-clone",
"left": 296, "top": 508,
"text": "Clone from git…",
"fontColor": "#e8ecf2",
"fontSize": 14
},
"row-clone-key": {
"type": "Text",
"parent": "row-clone",
"left": 590, "top": 510,
"text": "⌘G",
"fontColor": "#7a8294",
"fontSize": 12
},
"row-claude": {
"type": "Rectangle",
"parent": "canvas",
"left": 250, "top": 538, "width": 380, "height": 36,
"fillColor": "#0e1014",
"strokeColor": "#0e1014"
},
"row-claude-icon": {
"type": "Text",
"parent": "row-claude",
"left": 262, "top": 546,
"text": "◯",
"fontColor": "#7a8294",
"fontSize": 14
},
"row-claude-label": {
"type": "Text",
"parent": "row-claude",
"left": 296, "top": 548,
"text": "Start a Claude session",
"fontColor": "#e8ecf2",
"fontSize": 14
},
"row-claude-key": {
"type": "Text",
"parent": "row-claude",
"left": 590, "top": 550,
"text": "⌘C",
"fontColor": "#7a8294",
"fontSize": 12
},
"recent-label": {
"type": "Text",
"parent": "canvas",
"left": 700, "top": 426,
"text": "RECENT",
"fontColor": "#7a8294",
"fontSize": 11
},
"recent-row": {
"type": "Rectangle",
"parent": "canvas",
"left": 700, "top": 458, "width": 410, "height": 56,
"fillColor": "#13161c",
"strokeColor": "#1c2028",
"corners": [4, 4, 4, 4]
},
"recent-name": {
"type": "Text",
"parent": "recent-row",
"left": 716, "top": 468,
"text": "clide",
"fontColor": "#e8ecf2",
"fontSize": 14
},
"recent-meta": {
"type": "Text",
"parent": "recent-row",
"left": 716, "top": 490,
"text": "/var/mnt/data/projects/clide · ⑂ main",
"fontColor": "#7a8294",
"fontSize": 12
},
"recent-time": {
"type": "Text",
"parent": "recent-row",
"left": 1056, "top": 480,
"text": "just now",
"fontColor": "#7a8294",
"fontSize": 12
},
"tips-card": {
"type": "Rectangle",
"parent": "canvas",
"left": 250, "top": 612, "width": 860, "height": 110,
"fillColor": "#13161c",
"strokeColor": "#1c2028",
"corners": [6, 6, 6, 6]
},
"tips-label": {
"type": "Text",
"parent": "tips-card",
"left": 274, "top": 628,
"text": "TIPS",
"fontColor": "#7a8294",
"fontSize": 11
},
"tip-1-label": {
"type": "Text",
"parent": "tips-card",
"left": 274, "top": 660,
"text": "Quick open",
"fontColor": "#7a8294",
"fontSize": 13
},
"tip-1-key": {
"type": "Text",
"parent": "tips-card",
"left": 488, "top": 662,
"text": "⌘P",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tip-2-label": {
"type": "Text",
"parent": "tips-card",
"left": 558, "top": 660,
"text": "Command palette",
"fontColor": "#7a8294",
"fontSize": 13
},
"tip-2-key": {
"type": "Text",
"parent": "tips-card",
"left": 766, "top": 662,
"text": "⌘⇧P",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tip-3-label": {
"type": "Text",
"parent": "tips-card",
"left": 836, "top": 660,
"text": "Toggle sidebar",
"fontColor": "#7a8294",
"fontSize": 13
},
"tip-3-key": {
"type": "Text",
"parent": "tips-card",
"left": 1056, "top": 662,
"text": "⌘B",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tip-4-label": {
"type": "Text",
"parent": "tips-card",
"left": 274, "top": 690,
"text": "Toggle context",
"fontColor": "#7a8294",
"fontSize": 13
},
"tip-4-key": {
"type": "Text",
"parent": "tips-card",
"left": 488, "top": 692,
"text": "⌘J",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tip-5-label": {
"type": "Text",
"parent": "tips-card",
"left": 558, "top": 690,
"text": "Switch theme",
"fontColor": "#7a8294",
"fontSize": 13
},
"tip-5-key": {
"type": "Text",
"parent": "tips-card",
"left": 754, "top": 692,
"text": "⌘K ⌘T",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tip-6-label": {
"type": "Text",
"parent": "tips-card",
"left": 836, "top": 690,
"text": "New Claude session",
"fontColor": "#7a8294",
"fontSize": 13
},
"tip-6-key": {
"type": "Text",
"parent": "tips-card",
"left": 1054, "top": 692,
"text": "⌘⇧C",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"status-version": {
"type": "Text",
"parent": "canvas",
"left": 940, "top": 798,
"text": "clide 2.0.0-dev",
"fontColor": "#7a8294",
"fontSize": 11
},
"status-app": {
"type": "Text",
"parent": "canvas",
"left": 1058, "top": 798,
"text": "· application ok",
"fontColor": "#5a8c5a",
"fontSize": 11
},
"status-theme": {
"type": "Text",
"parent": "canvas",
"left": 1180, "top": 798,
"text": "· theme: clide",
"fontColor": "#7a8294",
"fontSize": 11
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+85 -68
View File
@@ -5,24 +5,13 @@ import 'dart:io';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart'; import 'package:clide/src/terminal/terminal.dart';
import 'session_naming.dart'; import 'session_naming.dart';
import 'tmux_session.dart' as tmux;
/// Claude pane. Opinionated per D-041:
///
/// - [isPrimary]=true: the session name is stable per repo
/// (`clide-claude-<hash>`) so reopening the app re-attaches to a
/// running `claude` under tmux. No close button rendered —
/// close-gestures (tab × on the header) minimise, not kill.
/// - [isPrimary]=false: session name includes a `-N` suffix for
/// this clide run. Closes normally; `pane.close` kills the tmux
/// session.
///
/// Requires `tmux` on the daemon's PATH. If it isn't there, the pane
/// falls back to spawning `claude` directly and loses persistence —
/// an explicit state message lands in the header subtitle.
class ClaudePane extends StatefulWidget { class ClaudePane extends StatefulWidget {
const ClaudePane({ const ClaudePane({
super.key, super.key,
@@ -33,8 +22,6 @@ class ClaudePane extends StatefulWidget {
final bool isPrimary; final bool isPrimary;
final bool showChrome; final bool showChrome;
/// 1-based secondary-session index. Ignored when [isPrimary].
final int? secondaryIndex; final int? secondaryIndex;
@override @override
@@ -42,7 +29,8 @@ class ClaudePane extends StatefulWidget {
} }
class _ClaudePaneState extends State<ClaudePane> { class _ClaudePaneState extends State<ClaudePane> {
static const _maxLines = 5000; static const _maxLines = 50000;
static String? _tmuxConfPath;
late final Terminal _terminal; late final Terminal _terminal;
StreamSubscription<DaemonEvent>? _eventSub; StreamSubscription<DaemonEvent>? _eventSub;
@@ -57,8 +45,8 @@ class _ClaudePaneState extends State<ClaudePane> {
void initState() { void initState() {
super.initState(); super.initState();
_terminal = Terminal(maxLines: _maxLines); _terminal = Terminal(maxLines: _maxLines);
_terminal.onOutput = _onOutput; _terminal.onOutput = _onTerminalOutput;
_terminal.onResize = _onResize; _terminal.onResize = _onTerminalResize;
// Don't spawn here — wait for the first onResize from TerminalView // Don't spawn here — wait for the first onResize from TerminalView
// so the PTY gets real dimensions, not 80x24 defaults. // so the PTY gets real dimensions, not 80x24 defaults.
} }
@@ -70,24 +58,49 @@ class _ClaudePaneState extends State<ClaudePane> {
_eventSub?.cancel(); _eventSub?.cancel();
_eventSub = null; _eventSub = null;
final id = _paneId; final id = _paneId;
final sessionName = _sessionName;
_paneId = null; _paneId = null;
// Secondary panes own their tmux session — close on dispose.
// Primary panes leave the tmux session alive so the next launch
// re-attaches via `tmux new-session -A` (D-41).
//
// pane.close kills the ptyc-spawned tmux *client*; the tmux server
// keeps the session alive. We need an explicit kill-session for
// secondaries to actually disappear (D-41 close semantics).
if (id != null && !widget.isPrimary) { if (id != null && !widget.isPrimary) {
// Secondary: killing the pane kills the tmux session too —
// that's the D-041 policy ("closing a secondary pops back to
// primary"). The daemon's pane.close is idempotent.
unawaited(_ipc()?.request('pane.close', args: {'id': id})); unawaited(_ipc()?.request('pane.close', args: {'id': id}));
if (sessionName != null) {
unawaited(tmux.killSession(sessionName));
}
} }
// Primary: don't close on dispose. The next time this pane is
// rebuilt (next app launch, or tab reopen), tmux new-session -A
// re-attaches to the same running claude.
super.dispose(); super.dispose();
} }
// -- tmux config extraction -----------------------------------------------
static Future<String?> _ensureTmuxConf() async {
if (_tmuxConfPath != null) return _tmuxConfPath;
try {
final content = await rootBundle.loadString('assets/clide.tmux.conf');
final dir = Directory(
'${Platform.environment['HOME'] ?? '/tmp'}/.config/clide',
);
if (!dir.existsSync()) dir.createSync(recursive: true);
final file = File('${dir.path}/tmux.conf');
file.writeAsStringSync(content);
_tmuxConfPath = file.path;
return _tmuxConfPath;
} catch (_) {
return null;
}
}
// -- spawn ----------------------------------------------------------------
Future<void> _spawnWhenReady() async { Future<void> _spawnWhenReady() async {
if (!mounted) return; if (!mounted) return;
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
if (!kernel.project.isOpen) { if (!kernel.project.isOpen) {
// Wait for a project to open before spawning.
final c = Completer<void>(); final c = Completer<void>();
late final StreamSubscription<ProjectOpened> sub; late final StreamSubscription<ProjectOpened> sub;
sub = kernel.events.on<ProjectOpened>().listen((_) { sub = kernel.events.on<ProjectOpened>().listen((_) {
@@ -106,28 +119,28 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return; if (!mounted) return;
final ipc = _ipc(); final ipc = _ipc();
if (ipc == null || !ipc.isConnected) { if (ipc == null || !ipc.isConnected) {
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.'); setState(() => _error = 'Daemon not connected.');
return; return;
} }
// Resolve repo root via files.root. If that fails (no daemon, no
// git root), fall back to cwd — the session name will just be
// based on wherever the daemon is running.
String repoRoot = Directory.current.path; String repoRoot = Directory.current.path;
final rootResp = await ipc.request('files.root'); final rootResp = await ipc.request('files.root');
if (rootResp.ok) { if (rootResp.ok) {
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot; repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
} }
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); _sessionName = widget.isPrimary
? primarySessionName(repoRoot)
: secondarySessionName(repoRoot, widget.secondaryIndex!);
// tmux-wrapped session for persistence (D-041). final tmuxConf = await _ensureTmuxConf();
// -x/-y set the initial window size; without them tmux defaults
// to a huge size when running inside a PTY without a real terminal.
final cols = _terminal.viewWidth; final cols = _terminal.viewWidth;
final rows = _terminal.viewHeight; final rows = _terminal.viewHeight;
var argv = <String>[ var argv = <String>[
'tmux', 'tmux',
'-L', 'clide',
if (tmuxConf != null) ...['-f', tmuxConf],
'new-session', 'new-session',
'-A', '-A',
'-s', '-s',
@@ -136,33 +149,37 @@ class _ClaudePaneState extends State<ClaudePane> {
'$cols', '$cols',
'-y', '-y',
'$rows', '$rows',
'claude',
]; ];
print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}');
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
// input box pinned to the bottom of the alt-screen, claude owns
// its own scrollback. Removes the need for tmux scroll forwarding.
final env = {'CLAUDE_CODE_NO_FLICKER': '1'};
var resp = await ipc.request('pane.spawn', args: { var resp = await ipc.request('pane.spawn', args: {
'argv': argv, 'argv': argv,
'kind': PaneKind.claude.wire, 'kind': PaneKind.claude.wire,
'cwd': repoRoot, 'cwd': repoRoot,
'cols': _terminal.viewWidth, 'cols': cols,
'rows': _terminal.viewHeight, 'rows': rows,
'title': _sessionName, 'title': _sessionName,
'env': env,
}); });
if (!resp.ok) { if (!resp.ok) {
// tmux probably missing — try bare claude so the pane still
// works, at the cost of persistence.
argv = ['claude']; argv = ['claude'];
resp = await ipc.request('pane.spawn', args: { resp = await ipc.request('pane.spawn', args: {
'argv': argv, 'argv': argv,
'kind': PaneKind.claude.wire, 'kind': PaneKind.claude.wire,
'cwd': repoRoot, 'cwd': repoRoot,
'cols': _terminal.viewWidth, 'cols': cols,
'rows': _terminal.viewHeight, 'rows': rows,
'title': _sessionName, 'title': _sessionName,
'env': env,
}); });
if (!resp.ok) { if (!resp.ok) {
setState(() { setState(() => _error = resp.error?.message ?? 'spawn failed');
_error = resp.error?.message ?? 'spawn failed';
});
return; return;
} }
setState(() => _statusLine = 'no-tmux · fresh every launch'); setState(() => _statusLine = 'no-tmux · fresh every launch');
@@ -172,11 +189,12 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return; if (!mounted) return;
_paneId = resp.data['id'] as String?; _paneId = resp.data['id'] as String?;
// PID available in resp.data['pid'] if needed for debugging.
_subscribe(); _subscribe();
setState(() {}); setState(() {});
} }
// -- output batching ------------------------------------------------------
final _outputBuf = StringBuffer(); final _outputBuf = StringBuffer();
Timer? _flushTimer; Timer? _flushTimer;
@@ -197,30 +215,23 @@ class _ClaudePaneState extends State<ClaudePane> {
final b64 = e.data['bytes_b64']; final b64 = e.data['bytes_b64'];
if (b64 is String) { if (b64 is String) {
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true)); _outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
// Batch all output from the current event loop turn into one
// terminal.write() call. scheduleMicrotask runs after all
// pending events but before the next frame, so split escape
// sequences within the same event batch are reunited.
if (_flushTimer == null) { if (_flushTimer == null) {
_flushTimer = Timer(Duration.zero, _flushOutput); _flushTimer = Timer(Duration.zero, _flushOutput);
} }
} }
case 'pane.exit': case 'pane.exit':
if (widget.isPrimary) { setState(() => _statusLine = widget.isPrimary
// Primary exiting is unusual — tmux sessions survive ? 'session exited — restart clide to retry'
// normal disconnects. Surface it but don't auto-respawn; : 'session exited');
// the user decides.
setState(() => _statusLine = 'session exited — restart clide to retry');
} else {
setState(() => _statusLine = 'session exited');
}
case 'pane.closed': case 'pane.closed':
_paneId = null; _paneId = null;
} }
}); });
} }
void _onOutput(String text) { // -- terminal callbacks ---------------------------------------------------
void _onTerminalOutput(String text) {
final id = _paneId; final id = _paneId;
if (id == null) return; if (id == null) return;
_ipc()?.request('pane.write', args: {'id': id, 'text': text}); _ipc()?.request('pane.write', args: {'id': id, 'text': text});
@@ -228,29 +239,30 @@ class _ClaudePaneState extends State<ClaudePane> {
Timer? _resizeTimer; Timer? _resizeTimer;
void _onResize(int cols, int rows, int _, int __) { void _onTerminalResize(int cols, int rows, int _, int __) {
print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId');
if (!_spawned) { if (!_spawned) {
// First resize — TerminalView has real dimensions now.
_spawned = true; _spawned = true;
_spawnWhenReady(); _spawnWhenReady();
return; return;
} }
// Debounce resize — rapid SIGWINCH during window drag corrupts
// the terminal rendering. Wait for the resize to settle.
_resizeTimer?.cancel(); _resizeTimer?.cancel();
_resizeTimer = Timer(const Duration(milliseconds: 150), () { _resizeTimer = Timer(const Duration(milliseconds: 150), () {
final id = _paneId; final id = _paneId;
if (id == null) return; if (id == null) return;
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); _ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
// tmux sizes windows by client, not PTY winsize. Explicitly
// resize the tmux window to match the TerminalView dimensions.
if (_sessionName != null) { if (_sessionName != null) {
Process.run('tmux', ['resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']); Process.run('tmux', [
'-L', 'clide', 'resize-window',
'-t', _sessionName!,
'-x', '$cols',
'-y', '$rows',
]);
} }
}); });
} }
// -- helpers --------------------------------------------------------------
DaemonClient? _ipc() => _kernel()?.ipc; DaemonClient? _ipc() => _kernel()?.ipc;
KernelServices? _kernel() { KernelServices? _kernel() {
@@ -261,15 +273,20 @@ class _ClaudePaneState extends State<ClaudePane> {
} }
} }
// -- build ----------------------------------------------------------------
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}'; final title = widget.isPrimary
? 'claude — primary'
: 'claude — secondary ${widget.secondaryIndex}';
final body = _error != null final body = _error != null
? Padding( ? Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: ClideText(_error!, muted: true), child: ClideText(_error!, muted: true),
) )
: ClidePtyView(terminal: _terminal, label: title); : ClidePtyView(terminal: _terminal, label: title, autofocus: true);
if (!widget.showChrome) return body; if (!widget.showChrome) return body;
+48 -152
View File
@@ -1,9 +1,13 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'claude_pane.dart'; import 'claude_pane.dart';
/// Hosts the primary Claude pane plus N user-spawned secondary
/// sessions per D-41. Uses [MultitabPane] for the tab strip
/// (drag-reorder, close ×, + button) and [IndexedStack]-mode
/// keep-alive so switching tabs doesn't tear down the underlying
/// PTY-backed terminal.
class ClaudeSessionHost extends StatefulWidget { class ClaudeSessionHost extends StatefulWidget {
const ClaudeSessionHost({super.key}); const ClaudeSessionHost({super.key});
@@ -12,175 +16,67 @@ class ClaudeSessionHost extends StatefulWidget {
} }
class ClaudeSessionHostState extends State<ClaudeSessionHost> { class ClaudeSessionHostState extends State<ClaudeSessionHost> {
final List<_Session> _sessions = []; static const _primaryId = 'primary';
int _activeIndex = 0;
late final MultitabController<_Session> _controller;
int _nextSecondary = 1; int _nextSecondary = 1;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_sessions.add(_Session(isPrimary: true, label: 'primary')); _controller = MultitabController<_Session>(
initial: [
MultitabEntry<_Session>(
id: _primaryId,
title: 'primary',
payload: const _Session(isPrimary: true),
// Primary persists across clide restarts and never gets a
// close affordance (D-41).
closeable: false,
reorderable: false,
),
],
);
} }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Public entry point used by the `claude.new-secondary` command.
void addSecondary() { void addSecondary() {
final index = _nextSecondary++; final index = _nextSecondary++;
setState(() { _controller.add(MultitabEntry<_Session>(
_sessions.add(_Session(isPrimary: false, secondaryIndex: index, label: 'session $index')); id: 'secondary-$index',
_activeIndex = _sessions.length - 1; title: 'session $index',
}); payload: _Session(isPrimary: false, secondaryIndex: index),
} ));
void _close(int index) {
if (index < 0 || index >= _sessions.length) return;
if (_sessions[index].isPrimary) return;
setState(() {
_sessions.removeAt(index);
if (_activeIndex >= _sessions.length) _activeIndex = _sessions.length - 1;
if (_activeIndex < 0) _activeIndex = 0;
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; return MultitabPane<_Session>(
final showTabs = _sessions.length > 1; controller: _controller,
keepAlive: true,
return Column( onAddRequested: addSecondary,
children: [ bodyBuilder: (ctx, entry) {
if (showTabs) final s = entry.payload;
_TabRow( return ClaudePane(
sessions: _sessions, isPrimary: s.isPrimary,
activeIndex: _activeIndex, secondaryIndex: s.secondaryIndex,
tokens: tokens, // The MultitabPane already provides the tab strip header;
onSelect: (i) => setState(() => _activeIndex = i), // suppressing the ClaudePane's own chrome avoids a double row.
onClose: _close, showChrome: false,
onAdd: addSecondary, );
), },
Expanded(
child: IndexedStack(
index: _activeIndex,
children: [
for (final s in _sessions)
ClaudePane(
key: s.key,
isPrimary: s.isPrimary,
secondaryIndex: s.secondaryIndex,
showChrome: !showTabs,
),
],
),
),
],
); );
} }
} }
class _Session { class _Session {
_Session({required this.isPrimary, this.secondaryIndex, required this.label}) : key = GlobalKey(); const _Session({required this.isPrimary, this.secondaryIndex});
final bool isPrimary; final bool isPrimary;
final int? secondaryIndex; final int? secondaryIndex;
final String label;
final GlobalKey key;
}
class _TabRow extends StatelessWidget {
const _TabRow({
required this.sessions,
required this.activeIndex,
required this.tokens,
required this.onSelect,
required this.onClose,
required this.onAdd,
});
final List<_Session> sessions;
final int activeIndex;
final SurfaceTokens tokens;
final ValueChanged<int> onSelect;
final ValueChanged<int> onClose;
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTap: onAdd,
child: Container(
height: 28,
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
child: Row(
children: [
for (var i = 0; i < sessions.length; i++)
_Tab(
session: sessions[i],
active: i == activeIndex,
tokens: tokens,
onTap: () => onSelect(i),
onClose: sessions[i].isPrimary ? null : () => onClose(i)),
const SizedBox(width: 4),
_AddButton(tokens: tokens, onTap: onAdd),
const Spacer(),
],
),
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({required this.session, required this.active, required this.tokens, required this.onTap, this.onClose});
final _Session session;
final bool active;
final SurfaceTokens tokens;
final VoidCallback onTap;
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
return ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: hovered && !active ? tokens.tabInactive : null,
border: Border(bottom: BorderSide(color: active ? tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideText(
session.label,
fontSize: 12,
color: active ? tokens.tabActiveForeground : tokens.tabInactiveForeground,
fontFamily: clideMonoFamily,
),
if (onClose != null) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: onClose,
child: ClideIcon(PhosphorIcons.xMark, size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
],
],
),
),
);
}
}
class _AddButton extends StatelessWidget {
const _AddButton({required this.tokens, required this.onTap});
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ClideTappable(
onTap: onTap,
tooltip: 'New session',
builder: (context, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: ClideText('+', fontSize: 14, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
);
}
} }
+45 -9
View File
@@ -1,5 +1,7 @@
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
import 'package:clide/extension/extension.dart'; import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -48,29 +50,63 @@ class ClaudeExtension extends ClideExtension {
@override @override
Future<void> activate(ClideExtensionContext ctx) async { Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx; _ctx = ctx;
// Cold-start reap: kill any leftover secondary tmux sessions from
// a previous run. D-41's "secondary numbering resets between
// clide runs" only holds if the leftovers are gone before the new
// run starts. Doing this in activate (rather than the previous
// run's deactivate) guarantees cleanup even after an abrupt exit
// — Flutter's deactivate hook only fires on explicit extension
// teardown, not on app quit / kill -9 / OOM.
final primary = await _primarySessionName();
if (primary != null) await tmux.reapSecondaries(primary);
} }
@override @override
Future<void> deactivate() async { Future<void> deactivate() async {
await _killAllSessions([]); // Best-effort cleanup on explicit extension teardown. The cold-
// start reap in activate is the actual safety net.
final primary = await _primarySessionName();
if (primary != null) await tmux.reapSecondaries(primary);
} }
/// Hard-reset command: kill every clide-claude tmux session for this
/// repo, primary included. The user invokes this when they want to
/// start over — typically after a tmux/Claude wedge.
Future<IpcResponse> _killAllSessions(List<String> args) async { Future<IpcResponse> _killAllSessions(List<String> args) async {
final ctx = _ctx; final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {}); if (ctx == null) return IpcResponse.ok(id: '', data: const {});
// Close the UI panes first so they don't try to talk to a tmux
// server that's about to lose their sessions.
final resp = await ctx.ipc.request('pane.list'); final resp = await ctx.ipc.request('pane.list');
if (!resp.ok) return resp; if (resp.ok) {
final panes = resp.data['panes']; final panes = resp.data['panes'];
if (panes is List) { if (panes is List) {
for (final p in panes) { for (final p in panes) {
if (p is Map && p['kind'] == 'claude') { if (p is Map && p['kind'] == 'claude') {
final id = p['id'] as String?; final id = p['id'] as String?;
if (id != null) { if (id != null) {
await ctx.ipc.request('pane.close', args: {'id': id}); await ctx.ipc.request('pane.close', args: {'id': id});
}
} }
} }
} }
} }
// Then kill the server-side sessions, primary included.
final primary = await _primarySessionName();
if (primary != null) await tmux.killAllForRepo(primary);
return IpcResponse.ok(id: '', data: const {'status': 'killed'}); return IpcResponse.ok(id: '', data: const {'status': 'killed'});
} }
Future<String?> _primarySessionName() async {
final ctx = _ctx;
if (ctx == null) return null;
final resp = await ctx.ipc.request('files.root');
if (!resp.ok) return null;
final root = resp.data['path'] as String?;
if (root == null) return null;
return primarySessionName(root);
}
} }
+61
View File
@@ -0,0 +1,61 @@
/// tmux server interactions for Claude panes (D-41 lifecycle).
///
/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is
/// client/server, so the server-side session keeps running after the
/// client disconnects. To honour D-41 ("closing a secondary kills that
/// tmux session" + "secondary numbering resets between clide runs"),
/// we need explicit `tmux kill-session` calls — that's what lives here.
library;
import 'dart:io';
/// Override-able runner so tests don't shell out for real.
typedef TmuxRunner = Future<ProcessResult> Function(List<String> args);
TmuxRunner tmuxRunner = _defaultRunner;
Future<ProcessResult> _defaultRunner(List<String> args) =>
Process.run('tmux', args);
const _socket = ['-L', 'clide'];
/// Kill the named tmux session on the clide socket. No-op if the
/// session does not exist (kill-session exits non-zero — we ignore it).
Future<void> killSession(String name) async {
await tmuxRunner([..._socket, 'kill-session', '-t', name]);
}
/// Return the names of all sessions currently alive on the clide
/// socket. Empty list if the server is not running.
Future<List<String>> listClideSessions() async {
final r = await tmuxRunner([..._socket, 'list-sessions', '-F', '#{session_name}']);
if (r.exitCode != 0) return const [];
return (r.stdout as String)
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
}
/// Kill every secondary clide-claude session whose name begins with
/// [primaryName] and ends with `-<digits>`. Leaves the primary itself
/// alive (D-41).
Future<void> reapSecondaries(String primaryName) async {
final pattern = RegExp('^${RegExp.escape(primaryName)}-\\d+\$');
for (final s in await listClideSessions()) {
if (pattern.hasMatch(s)) {
await killSession(s);
}
}
}
/// Kill every clide-claude session for [primaryName], including the
/// primary itself. Used by the explicit `claude.kill-all-sessions`
/// command when the user wants a hard reset.
Future<void> killAllForRepo(String primaryName) async {
for (final s in await listClideSessions()) {
if (s == primaryName || s.startsWith('$primaryName-')) {
await killSession(s);
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart'; import 'package:clide/src/terminal/terminal.dart';
/// General-purpose terminal pane. Spawns the user's `$SHELL` under the /// General-purpose terminal pane. Spawns the user's `$SHELL` under the
/// daemon's PTY (via `pane.spawn`), feeds the `pane.output` event /// daemon's PTY (via `pane.spawn`), feeds the `pane.output` event
+97 -23
View File
@@ -12,35 +12,109 @@ class WelcomeView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
return Stack( return LayoutBuilder(
children: [ builder: (context, c) {
Center( // Tips card sits below the START/RECENT row when there's room
child: ConstrainedBox( // for it; on shorter viewports the two centered columns win
constraints: const BoxConstraints(maxWidth: 850), // and the tips drop out cleanly.
child: Column( final showTips = c.maxHeight > 640;
mainAxisSize: MainAxisSize.min, return Stack(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Center(
_Header(tokens: tokens), child: ConstrainedBox(
const SizedBox(height: 56), constraints: const BoxConstraints(maxWidth: 850),
Row( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)), _Header(tokens: tokens),
const SizedBox(width: 56), const SizedBox(height: 56),
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)), Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)),
const SizedBox(width: 56),
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
],
),
if (showTips) ...[
const SizedBox(height: 48),
_TipsCard(tokens: tokens),
],
], ],
), ),
),
),
Positioned(
left: 64,
right: 64,
bottom: 24,
child: _StatusLine(tokens: tokens, kernel: kernel),
),
],
);
},
);
}
}
class _TipsCard extends StatelessWidget {
const _TipsCard({required this.tokens});
final SurfaceTokens tokens;
static const _tips = <(String, String)>[
('Quick open', '⌘P'),
('Command palette', '⌘⇧P'),
('Toggle sidebar', '⌘B'),
('Toggle context', '⌘J'),
('Switch theme', '⌘K ⌘T'),
('New Claude session', '⌘⇧C'),
];
@override
Widget build(BuildContext context) {
// Split tips into two rows of three so the card lays out as a
// 3-column grid matching the START/RECENT proportions above.
final firstRow = _tips.sublist(0, 3);
final secondRow = _tips.sublist(3);
return SizedBox(
width: double.infinity,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('TIPS', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 14),
_tipRow(firstRow),
const SizedBox(height: 8),
_tipRow(secondRow),
],
),
),
);
}
Widget _tipRow(List<(String, String)> tips) {
return Row(
children: [
for (var i = 0; i < tips.length; i++) ...[
Expanded(
child: Row(
children: [
Expanded(child: ClideText(tips[i].$1, fontSize: 13, color: tokens.globalTextMuted)),
ClideText(tips[i].$2, fontSize: 12, color: tokens.globalForeground, fontFamily: clideMonoFamily),
], ],
), ),
), ),
), if (i < tips.length - 1) const SizedBox(width: 24),
Positioned( ],
left: 64,
right: 64,
bottom: 24,
child: _StatusLine(tokens: tokens, kernel: kernel),
),
], ],
); );
} }
@@ -61,7 +135,7 @@ class _Header extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClideText('clide', fontSize: 52, fontWeight: FontWeight.w300, color: tokens.globalForeground), ClideText('clide', fontSize: 52, fontWeight: FontWeight.w300, color: tokens.globalForeground),
ClideText('Flutter desktop IDE for Claude Code', muted: true, fontSize: 16), ClideText('IDE for Claude Code CLI', muted: true, fontSize: 16),
], ],
), ),
], ],
+3 -3
View File
@@ -1,8 +1,8 @@
/// Manages the backend isolate lifecycle. /// Manages the backend isolate lifecycle.
/// ///
/// Two-phase boot: /// Two-phase boot:
/// 1. [spawn] — starts the isolate, resolves toolchain (binary checks only). /// 1. [Backend.spawn] — starts the isolate, resolves toolchain (binary checks only).
/// 2. [openWorkspace] — initializes services for a specific project root. /// 2. [Backend.openProject] — initializes services for a specific project root.
library; library;
import 'dart:async'; import 'dart:async';
@@ -34,7 +34,7 @@ class Backend {
int _validateId = 0; int _validateId = 0;
/// Spawn the backend isolate. Returns when the toolchain is resolved. /// Spawn the backend isolate. Returns when the toolchain is resolved.
/// No services are active yet — call [openWorkspace] to activate. /// No services are active yet — call [openProject] to activate.
static Future<Backend> spawn({ static Future<Backend> spawn({
required IsolateClient Function(SendPort backendPort) clientFactory, required IsolateClient Function(SendPort backendPort) clientFactory,
String? hintRoot, String? hintRoot,
@@ -1,6 +1,6 @@
{ {
"title": { "translation": "clide" }, "title": { "translation": "clide" },
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" }, "subtitle": { "translation": "IDE for Claude Code CLI" },
"open-project": { "translation": "Open project" }, "open-project": { "translation": "Open project" },
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" }, "open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
"tab.title": { "translation": "Welcome" } "tab.title": { "translation": "Welcome" }
+2 -1
View File
@@ -136,7 +136,8 @@ class I18n extends ChangeNotifier {
} }
/// [string] + naive `replaceAll` interpolation per replacer. /// [string] + naive `replaceAll` interpolation per replacer.
/// Matches fframe: replacers whose [from] isn't present are silent no-ops. /// Matches fframe: replacers whose [I18nReplacer.from] isn't present
/// are silent no-ops.
String interpolated( String interpolated(
String key, { String key, {
required String namespace, required String namespace,
+8 -4
View File
@@ -8,10 +8,14 @@ class InProcessClient extends DaemonClient {
InProcessClient({ InProcessClient({
required Logger log, required Logger log,
required DaemonBus events, required DaemonBus events,
required this.dispatcher, required DaemonDispatcher dispatcher,
}) : super(socketPath: '', log: log, events: events); }) : _dispatcher = dispatcher,
super(socketPath: '', log: log, events: events);
final DaemonDispatcher dispatcher; DaemonDispatcher _dispatcher;
DaemonDispatcher get dispatcher => _dispatcher;
set dispatcher(DaemonDispatcher d) => _dispatcher = d;
int _nextReqId = 0; int _nextReqId = 0;
@override @override
@@ -27,6 +31,6 @@ class InProcessClient extends DaemonClient {
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) { Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
final id = '${_nextReqId++}'; final id = '${_nextReqId++}';
final req = IpcRequest(id: id, cmd: cmd, args: args); final req = IpcRequest(id: id, cmd: cmd, args: args);
return dispatcher.dispatch(req); return _dispatcher.dispatch(req);
} }
} }
+2 -5
View File
@@ -68,14 +68,11 @@ class PanelRegistry extends ChangeNotifier {
List<TabContribution> tabsFor(SlotId id) { List<TabContribution> tabsFor(SlotId id) {
final tabs = contributionsFor(id).whereType<TabContribution>().toList(); final tabs = contributionsFor(id).whereType<TabContribution>().toList();
final order = _order[id]; final order = _order[id];
if (order == null || order.isEmpty) { if (order == null || order.isEmpty) return tabs;
tabs.sort((a, b) => a.priority.compareTo(b.priority));
return tabs;
}
tabs.sort((a, b) { tabs.sort((a, b) {
final ai = order.indexOf(a.id); final ai = order.indexOf(a.id);
final bi = order.indexOf(b.id); final bi = order.indexOf(b.id);
if (ai < 0 && bi < 0) return a.priority.compareTo(b.priority); if (ai < 0 && bi < 0) return 0;
if (ai < 0) return 1; if (ai < 0) return 1;
if (bi < 0) return -1; if (bi < 0) return -1;
return ai.compareTo(bi); return ai.compareTo(bi);
+2 -2
View File
@@ -6,8 +6,8 @@ import 'package:flutter/foundation.dart';
/// ///
/// The token surface grows as features need more of it. Every token /// The token surface grows as features need more of it. Every token
/// declared here must have a default resolution in /// declared here must have a default resolution in
/// [DefaultSurfaceMap] so legacy palette-only themes produce a complete /// `_defaultSurfaceMap` (resolver.dart) so legacy palette-only themes
/// SurfaceTokens without declaring the full surface. /// produce a complete SurfaceTokens without declaring the full surface.
@immutable @immutable
class SurfaceTokens { class SurfaceTokens {
const SurfaceTokens({ const SurfaceTokens({
+3 -4
View File
@@ -1,8 +1,8 @@
/// Centralized binary resolution for external tools. /// Centralized binary resolution for external tools.
/// ///
/// Resolution runs in a background isolate via [resolvePaths] to avoid /// Resolution runs in a background isolate via [Toolchain.resolvePaths]
/// blocking the merged UI/platform thread on macOS. The result is /// to avoid blocking the merged UI/platform thread on macOS. The result
/// applied on the main thread via [applyResolved]. /// is applied on the main thread via [Toolchain.applyResolved].
library; library;
import 'dart:async'; import 'dart:async';
@@ -54,7 +54,6 @@ class Toolchain extends ChangeNotifier {
if (_git == null) 'git', if (_git == null) 'git',
if (_pql == null) 'pql', if (_pql == null) 'pql',
if (_tmux == null) 'tmux', if (_tmux == null) 'tmux',
if (_ptyc == null) 'ptyc',
]; ];
/// Returns a Future that completes when resolution finishes. /// Returns a Future that completes when resolution finishes.
+30 -14
View File
@@ -71,6 +71,25 @@ Future<void> main() async {
toolchain.applyResolved(resolveToolchainPaths(root)); toolchain.applyResolved(resolveToolchainPaths(root));
} }
InProcessClient? ipcClient;
DaemonBus? daemonBus;
DaemonDispatcher _buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot) {
final dispatcher = DaemonDispatcher();
final eventSink = _BusEventSink(events);
final paneRegistry = PaneRegistry(events: eventSink);
registerPaneCommands(dispatcher, paneRegistry);
final filesService = FilesService(root: workRoot, events: eventSink);
registerFilesCommands(dispatcher, filesService);
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
registerEditorCommands(dispatcher, editorRegistry);
final gitClient = GitClient(toolchain: tc, workDir: workRoot);
registerGitCommands(dispatcher, gitClient, eventSink);
final pql = PqlClient(workDir: workRoot, toolchain: tc);
registerPqlCommands(dispatcher, pql);
return dispatcher;
}
final services = await KernelServices.boot( final services = await KernelServices.boot(
appDir: appDir, appDir: appDir,
bundledThemes: themes, bundledThemes: themes,
@@ -81,20 +100,17 @@ Future<void> main() async {
daemonClientFactory: kIsWeb daemonClientFactory: kIsWeb
? null ? null
: (log, events) { : (log, events) {
final dispatcher = DaemonDispatcher(); daemonBus = events;
final eventSink = _BusEventSink(events); final workRoot = FilesService.atCwd(events: _BusEventSink(events)).root;
final filesService = FilesService.atCwd(events: eventSink); final dispatcher = _buildDispatcher(events, toolchain, workRoot);
final workRoot = filesService.root; ipcClient = InProcessClient(log: log, events: events, dispatcher: dispatcher);
final paneRegistry = PaneRegistry(events: eventSink); return ipcClient!;
registerPaneCommands(dispatcher, paneRegistry); },
registerFilesCommands(dispatcher, filesService); onProjectOpen: kIsWeb
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot); ? null
registerEditorCommands(dispatcher, editorRegistry); : (path) async {
final gitClient = GitClient(toolchain: toolchain, workDir: workRoot); if (ipcClient == null || daemonBus == null) return;
registerGitCommands(dispatcher, gitClient, eventSink); ipcClient!.dispatcher = _buildDispatcher(daemonBus!, toolchain, Directory(path));
final pql = PqlClient(workDir: workRoot, toolchain: toolchain);
registerPqlCommands(dispatcher, pql);
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
}, },
); );
+19
View File
@@ -9,8 +9,11 @@
/// one-to-one onto these in `bin/clide.dart`. /// one-to-one onto these in `bin/clide.dart`.
library; library;
import 'dart:io' show FileSystemException;
import '../editor/registry.dart'; import '../editor/registry.dart';
import '../ipc/envelope.dart'; import '../ipc/envelope.dart';
import '../ipc/errno_mapping.dart';
import '../ipc/schema_v1.dart'; import '../ipc/schema_v1.dart';
import 'dispatcher.dart'; import 'dispatcher.dart';
@@ -64,6 +67,22 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
try { try {
final buf = await r.open(path); final buf = await r.open(path);
return IpcResponse.ok(id: req.id, data: buf.toJson()); return IpcResponse.ok(id: req.id, data: buf.toJson());
} on FileSystemException catch (e) {
final errno = e.osError?.errorCode;
if (errno != null) {
return IpcResponse.err(
id: req.id,
error: errnoToIpcError(errno: errno, op: 'editor.open', target: path),
);
}
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'editor.open failed: ${e.message}',
),
);
} catch (e) { } catch (e) {
return IpcResponse.err( return IpcResponse.err(
id: req.id, id: req.id,
+15 -1
View File
@@ -6,6 +6,7 @@ import 'dart:io';
import '../files/ignore.dart'; import '../files/ignore.dart';
import '../files/listing.dart'; import '../files/listing.dart';
import '../files/path_safety.dart';
import '../files/watcher.dart'; import '../files/watcher.dart';
import '../ipc/envelope.dart'; import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart'; import '../ipc/schema_v1.dart';
@@ -71,7 +72,13 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
if (path == null || path.isEmpty) { if (path == null || path.isEmpty) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'files.read requires a path')); return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'files.read requires a path'));
} }
final file = File('${files.root.absolute.path}/$path'); final String absPath;
try {
absPath = resolveUnderRoot(files.root, path);
} on PathOutsideRoot {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'));
}
final file = File(absPath);
if (!file.existsSync()) { if (!file.existsSync()) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path')); return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
} }
@@ -81,6 +88,13 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
d.register('files.ls', (req) async { d.register('files.ls', (req) async {
final dir = (req.args['path'] as String?) ?? ''; final dir = (req.args['path'] as String?) ?? '';
if (dir.isNotEmpty) {
try {
resolveUnderRoot(files.root, dir);
} on PathOutsideRoot {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $dir'));
}
}
final entries = await listDir( final entries = await listDir(
root: files.root, root: files.root,
dir: dir, dir: dir,
+22
View File
@@ -12,9 +12,11 @@ library;
import 'dart:convert'; import 'dart:convert';
import '../ipc/envelope.dart'; import '../ipc/envelope.dart';
import '../ipc/errno_mapping.dart';
import '../ipc/schema_v1.dart'; import '../ipc/schema_v1.dart';
import '../panes/pane.dart'; import '../panes/pane.dart';
import '../panes/registry.dart'; import '../panes/registry.dart';
import '../pty/errors.dart';
import 'dispatcher.dart'; import 'dispatcher.dart';
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) { void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
@@ -84,6 +86,26 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
title: args['title'] as String?, title: args['title'] as String?,
); );
return IpcResponse.ok(id: req.id, data: pane.toJson()); return IpcResponse.ok(id: req.id, data: pane.toJson());
} on PtyException catch (e) {
final errno = e.errno;
if (errno != null) {
return IpcResponse.err(
id: req.id,
error: errnoToIpcError(
errno: errno,
op: 'pane.spawn',
target: argv.isNotEmpty ? argv.first : null,
),
);
}
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'pane.spawn failed: ${e.message}',
),
);
} catch (e) { } catch (e) {
return IpcResponse.err( return IpcResponse.err(
id: req.id, id: req.id,
+51
View File
@@ -0,0 +1,51 @@
/// Workspace-relative path validation. Rejects paths that resolve
/// outside the workspace root (path traversal via `..`, absolute
/// paths, symlink-out attempts).
library;
import 'dart:io';
class PathOutsideRoot implements Exception {
PathOutsideRoot(this.requested, this.resolved, this.root);
final String requested;
final String resolved;
final String root;
@override
String toString() => 'path outside workspace root: $requested → $resolved (root: $root)';
}
/// Resolve [relative] against [root] and verify the result is
/// contained within [root]. Returns the absolute, normalized path.
/// Throws [PathOutsideRoot] on traversal attempts.
String resolveUnderRoot(Directory root, String relative) {
final rootPath = _normalize(root.absolute.path);
final joined = _normalize('$rootPath${Platform.pathSeparator}$relative');
// Containment check: joined must equal rootPath, or start with
// rootPath + separator. Equality covers `relative == ''` (the
// root itself); the separator check prevents `/repo` matching
// `/repository`.
if (joined != rootPath && !joined.startsWith('$rootPath${Platform.pathSeparator}')) {
throw PathOutsideRoot(relative, joined, rootPath);
}
return joined;
}
String _normalize(String path) {
// Use Uri to collapse `..` and `.` segments without hitting the
// filesystem (Directory(...).resolveSymbolicLinksSync would also
// resolve symlinks, which we don't want here — symlink handling
// belongs at the filesystem-access layer, not the path layer).
final segments = <String>[];
for (final raw in path.split(Platform.pathSeparator)) {
if (raw.isEmpty || raw == '.') continue;
if (raw == '..') {
if (segments.isNotEmpty) segments.removeLast();
continue;
}
segments.add(raw);
}
final prefix = path.startsWith(Platform.pathSeparator) ? Platform.pathSeparator : '';
return '$prefix${segments.join(Platform.pathSeparator)}';
}
+103
View File
@@ -0,0 +1,103 @@
/// Map POSIX errno values to IPC error envelopes with actionable
/// messages. Used by command handlers that wrap syscall-backed work
/// (PTY spawn, file open) so the client can distinguish "binary not
/// found" from "permission denied" from "system limit hit" instead
/// of seeing the same generic `tool_error: foo failed`.
library;
import 'envelope.dart';
import 'schema_v1.dart';
/// Selected POSIX errno values we map specially. Others fall through
/// to a generic toolError. Values match Linux glibc and macOS Darwin
/// (the two platforms that share the same numbers for these entries).
abstract class PosixErrno {
static const int eperm = 1;
static const int enoent = 2;
static const int esrch = 3;
static const int eio = 5;
static const int ebadf = 9;
static const int eagain = 11;
static const int enomem = 12;
static const int eacces = 13;
static const int eexist = 17;
static const int enotdir = 20;
static const int eisdir = 21;
static const int emfile = 24;
static const int enfile = 23;
static const int epipe = 32;
}
/// Build an [IpcError] from a POSIX [errno] for an operation [op]
/// (e.g. `pane.spawn`, `editor.open`) on optional [target] (a path,
/// command name, etc.). The returned error uses `notFound`,
/// `userError`, or `toolError` based on what's actionable.
IpcError errnoToIpcError({
required int errno,
required String op,
String? target,
String? raw,
}) {
final what = target != null ? ' ($target)' : '';
switch (errno) {
case PosixErrno.enoent:
return IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: '$op: not found$what',
);
case PosixErrno.eacces:
case PosixErrno.eperm:
return IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: '$op: permission denied$what',
hint: 'check file permissions or run with appropriate access',
);
case PosixErrno.eisdir:
return IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: '$op: is a directory$what',
);
case PosixErrno.enotdir:
return IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: '$op: not a directory$what',
);
case PosixErrno.eexist:
return IpcError(
code: IpcExitCode.conflict,
kind: IpcErrorKind.conflict,
message: '$op: already exists$what',
);
case PosixErrno.emfile:
case PosixErrno.enfile:
return IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: '$op: too many open files',
hint: 'system or per-process file descriptor limit reached',
);
case PosixErrno.enomem:
return IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: '$op: out of memory',
);
case PosixErrno.eagain:
return IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: '$op: resource temporarily unavailable',
hint: 'retry may succeed',
);
default:
return IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: '$op failed${raw != null ? ': $raw' : ' (errno=$errno)'}',
);
}
}
+47 -11
View File
@@ -6,6 +6,12 @@ import 'package:clide/src/ipc/envelope.dart';
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request); typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
/// Default per-request timeout. A handler that doesn't return within
/// this window gets a `tool_error` response so the connection's read
/// pipeline isn't blocked indefinitely. Long-running commands (git
/// pull/push, large pql queries) can override per-command later.
const Duration _kDefaultRequestTimeout = Duration(seconds: 60);
/// Unix-socket JSON-lines server. Each connection is an independent /// Unix-socket JSON-lines server. Each connection is an independent
/// bidirectional line-framed stream: client writes requests, daemon /// bidirectional line-framed stream: client writes requests, daemon
/// writes responses + events on the same socket. /// writes responses + events on the same socket.
@@ -13,26 +19,27 @@ class DaemonServer {
DaemonServer({ DaemonServer({
required this.socketPath, required this.socketPath,
required this.dispatch, required this.dispatch,
}); Duration requestTimeout = _kDefaultRequestTimeout,
}) : _requestTimeout = requestTimeout;
final String socketPath; final String socketPath;
final RequestDispatcher dispatch; final RequestDispatcher dispatch;
final Duration _requestTimeout;
ServerSocket? _server; ServerSocket? _server;
final Set<Socket> _clients = {}; final Set<Socket> _clients = {};
/// Broadcast [event] to every currently-connected client. /// Broadcast [event] to every currently-connected client. Sockets
/// /// that error on write are dropped — the client's read side will
/// Future tuning: per-client subsystem/id filter (`tail --filter /// notice the close. Errors are logged so silent event loss is
/// pane:p_7`). For Tier 1 every client sees everything. Sockets /// debuggable.
/// that error on write are silently dropped; the client's read side
/// will notice the close.
void broadcast(IpcEvent event) { void broadcast(IpcEvent event) {
final line = event.encode(); final line = event.encode();
for (final c in List<Socket>.from(_clients)) { for (final c in List<Socket>.from(_clients)) {
try { try {
c.writeln(line); c.writeln(line);
} catch (_) { } catch (e) {
stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e');
_clients.remove(c); _clients.remove(c);
} }
} }
@@ -43,7 +50,19 @@ class DaemonServer {
try { try {
_server = await ServerSocket.bind(addr, 0); _server = await ServerSocket.bind(addr, 0);
} on SocketException { } on SocketException {
// stale socket from a prior crash — unlink and retry once // Either a stale socket from a prior crash, or a live daemon.
// Probe by trying to connect — if a live peer answers, refuse
// to start so we don't rip its socket out.
try {
final probe = await Socket.connect(addr, 0)
.timeout(const Duration(milliseconds: 200));
await probe.close();
throw StateError('clide daemon already running at $socketPath');
} on TimeoutException {
// No one answered — proceed to unlink and rebind.
} on SocketException {
// No one listening — proceed to unlink and rebind.
}
try { try {
await File(socketPath).delete(); await File(socketPath).delete();
} catch (_) {} } catch (_) {}
@@ -92,7 +111,17 @@ class DaemonServer {
if (msg is! IpcRequest) return; if (msg is! IpcRequest) return;
IpcResponse resp; IpcResponse resp;
try { try {
resp = await dispatch(msg); resp = await dispatch(msg).timeout(_requestTimeout);
} on TimeoutException {
stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}',
),
);
} catch (e, st) { } catch (e, st) {
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st'); stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
resp = IpcResponse.err( resp = IpcResponse.err(
@@ -104,6 +133,13 @@ class DaemonServer {
), ),
); );
} }
client.writeln(resp.encode()); try {
client.writeln(resp.encode());
} catch (e) {
// Client disconnected mid-dispatch — drop it so future events
// don't try to write to a dead socket.
stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e');
_clients.remove(client);
}
} }
} }
+119 -13
View File
@@ -17,6 +17,9 @@ import 'dart:typed_data';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'errors.dart';
import 'ffi/libc.dart' as libc;
// -- structs ---------------------------------------------------------------- // -- structs ----------------------------------------------------------------
final class _Winsize extends ffi.Struct { final class _Winsize extends ffi.Struct {
@@ -87,6 +90,13 @@ class NativePty {
final _out = StreamController<Uint8List>.broadcast(); final _out = StreamController<Uint8List>.broadcast();
bool _dead = false; bool _dead = false;
/// Tracks the reader isolate's spawn — close() awaits this before
/// tearing down so we never race a still-spawning isolate.
Future<void>? _readerReady;
Isolate? _readerIsolate;
ReceivePort? _readerPort;
Completer<void>? _readerExited;
NativePty._(this._fd, this.pid); NativePty._(this._fd, this.pid);
/// Byte stream of data produced by the child. /// Byte stream of data produced by the child.
@@ -125,6 +135,17 @@ class NativePty {
final execve = _execve; final execve = _execve;
final chdir = _chdir; final chdir = _chdir;
final exit = _exit_; final exit = _exit_;
final writeFn = _nativeWrite;
// Pre-allocate error envelopes the child will write to its stdout
// (slave PTY → parent's master fd) before _exit, so the parent's
// reader sees a real diagnostic instead of an indistinguishable EOF.
final chdirErr = 'clide: chdir failed: $workingDirectory\n'
.toNativeUtf8(allocator: malloc);
final chdirErrLen = chdirErr.length;
final execveErr = 'clide: exec failed: $executable\n'
.toNativeUtf8(allocator: malloc);
final execveErrLen = execveErr.length;
// Allocate ALL native memory before fork. // Allocate ALL native memory before fork.
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>(); final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>();
@@ -153,20 +174,33 @@ class NativePty {
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws); final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
if (pid == -1) { if (pid == -1) {
// Capture errno BEFORE _freeAll — free() can clobber errno.
final err = libc.errno;
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws); _freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
throw StateError('forkpty() failed'); malloc.free(chdirErr);
malloc.free(execveErr);
throw PtyException('forkpty', 'forkpty() failed', errno: err);
} }
if (pid == 0) { if (pid == 0) {
// CHILD — only pre-resolved FFI calls, no Dart heap. // CHILD — only pre-resolved FFI calls, no Dart heap.
chdir(wdN); // After forkpty(), fd 1 is the slave PTY connected back to the
// parent's master fd, so write(1, ...) lands as readable output.
if (chdir(wdN) != 0) {
writeFn(1, chdirErr.cast(), chdirErrLen);
exit(1);
}
execve(shellN, argvN, envpN); execve(shellN, argvN, envpN);
// execve only returns on failure.
writeFn(1, execveErr.cast(), execveErrLen);
exit(1); exit(1);
} }
// PARENT // PARENT
final fd = fdOut.value; final fd = fdOut.value;
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws); _freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
malloc.free(chdirErr);
malloc.free(execveErr);
final pty = NativePty._(fd, pid); final pty = NativePty._(fd, pid);
pty._spawnReader(); pty._spawnReader();
@@ -195,13 +229,32 @@ class NativePty {
// -- I/O ------------------------------------------------------------------ // -- I/O ------------------------------------------------------------------
void _spawnReader() async { void _spawnReader() {
_readerReady = _spawnReaderAsync();
}
Future<void> _spawnReaderAsync() async {
final rp = ReceivePort(); final rp = ReceivePort();
await Isolate.spawn(_readLoop, (rp.sendPort, _fd)); _readerPort = rp;
_readerExited = Completer<void>();
try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
} catch (e) {
// Surface the spawn failure instead of leaving the PTY in a
// half-alive state where output never flows but isClosed=false.
_dead = true;
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
return;
}
rp.listen((msg) { rp.listen((msg) {
if (msg == null) { if (msg == null) {
if (!_out.isClosed) _out.close(); if (!_out.isClosed) _out.close();
rp.close(); rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
_reap(); _reap();
} else { } else {
if (!_out.isClosed) _out.add(msg as Uint8List); if (!_out.isClosed) _out.add(msg as Uint8List);
@@ -240,26 +293,52 @@ class NativePty {
port.send(null); port.send(null);
} }
/// Write bytes to the child's stdin. /// Write bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns the total bytes
/// written, which is always [bytes.length] on success.
int write(List<int> bytes) { int write(List<int> bytes) {
if (_dead || bytes.isEmpty) return 0; if (_dead || bytes.isEmpty) return 0;
final buf = malloc<ffi.Uint8>(bytes.length); final buf = malloc<ffi.Uint8>(bytes.length);
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i]; try {
final n = _nativeWrite(_fd, buf.cast(), bytes.length); for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
malloc.free(buf); var written = 0;
return n; while (written < bytes.length) {
final n = _nativeWrite(
_fd,
buf.elementAt(written).cast(),
bytes.length - written,
);
if (n < 0) {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
if (err == 9 /* EBADF */ || err == 32 /* EPIPE */) _dead = true;
throw PtyException('write', 'write to PTY failed', errno: err);
}
if (n == 0) break;
written += n;
}
return written;
} finally {
malloc.free(buf);
}
} }
/// Resize the terminal. /// Resize the terminal. Silently no-ops if the fd is already
/// closed; flips [_dead] on EBADF so subsequent calls short-circuit.
void resize({required int cols, required int rows}) { void resize({required int cols, required int rows}) {
if (_dead) return; if (_dead) return;
final ws = calloc<_Winsize>() final ws = calloc<_Winsize>()
..ref.wsRow = rows ..ref.wsRow = rows
..ref.wsCol = cols; ..ref.wsCol = cols;
_ioctl(_fd, _kTiocsWinsz, ws); final rc = _ioctl(_fd, _kTiocsWinsz, ws);
calloc.free(ws); calloc.free(ws);
if (rc < 0 && libc.errno == 9 /* EBADF */) {
_dead = true;
return;
}
// Explicitly signal the child to re-query its terminal size. // Explicitly signal the child to re-query its terminal size.
_nativeKill(pid, 28); // SIGWINCH = 28 on macOS/Linux // SIGWINCH = 28 on both macOS and Linux.
_nativeKill(pid, 28);
} }
/// Send a signal to the child. /// Send a signal to the child.
@@ -277,12 +356,39 @@ class NativePty {
} }
/// Kill the child and release resources. /// Kill the child and release resources.
///
/// Order matters: kill the child first so its slave PTY closes,
/// causing the master fd to return EOF. The reader isolate sees
/// EOF and exits cleanly. Only then do we close the master fd —
/// closing it before the isolate exits creates a window where the
/// fd number could be reused and the isolate would briefly poll
/// the wrong file.
Future<void> close() async { Future<void> close() async {
if (_dead) return; if (_dead) return;
_dead = true; _dead = true;
_nativeClose(_fd);
// Make sure the reader is fully spawned before we tear it down —
// otherwise close() racing with start() leaves an orphan isolate.
await _readerReady;
_nativeKill(pid, _kSighup); _nativeKill(pid, _kSighup);
_nativeKill(pid, 9); _nativeKill(pid, 9);
// Wait for the isolate to send `null` (EOF) — confirms it has
// exited its poll loop and won't touch the fd again.
if (_readerExited != null) {
await _readerExited!.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {},
);
}
_nativeClose(_fd);
_readerIsolate?.kill(priority: Isolate.immediate);
_readerIsolate = null;
_readerPort?.close();
_readerPort = null;
final s = calloc<ffi.Int32>(); final s = calloc<ffi.Int32>();
_waitpid(pid, s, 0); _waitpid(pid, s, 0);
calloc.free(s); calloc.free(s);
+75 -29
View File
@@ -132,36 +132,51 @@ class PtySession {
// Receive the master fd over the parent side of the socketpair. // Receive the master fd over the parent side of the socketpair.
// recvFd blocks until ptyc sends — run in a child isolate so the // recvFd blocks until ptyc sends — run in a child isolate so the
// calling isolate's event loop stays responsive. // calling isolate's event loop stays responsive.
final masterFd = await _recvFdAsync(parentSock); final int masterFd;
try {
// Apply initial winsize (ptyc already did this, but doing it masterFd = await _recvFdAsync(parentSock);
// again from Dart confirms the wire + gives a place to call it } catch (_) {
// when resize() lands). proc.kill();
libc.setWinsize(masterFd, cols, rows); rethrow;
// Drain ptyc's stdout to parse the success envelope. We don't
// strictly need it — the fd arriving is proof-of-life — but
// draining avoids a PIPE accumulating.
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
final pid = _extractPid(stdoutLine);
final code = await proc.exitCode;
if (code != 0) {
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
libc.close(masterFd);
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
} }
return PtySession._(pid: pid, masterFd: masterFd); // Once we own masterFd, every error path below must close it
// before rethrowing. Wrap the rest of the spawn in its own
// try/catch so the cleanup is centralized.
try {
libc.setWinsize(masterFd, cols, rows);
final stdoutLine = await proc.stdout
.transform(const Utf8Decoder())
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 5));
final pid = _extractPid(stdoutLine);
final code = await proc.exitCode;
if (code != 0) {
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
libc.close(masterFd);
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
}
return PtySession._(pid: pid, masterFd: masterFd);
} catch (_) {
libc.close(masterFd);
rethrow;
}
} finally { } finally {
// parent keeps its own fd until the session is closed; ptyc-side // parent keeps its own fd until the session is closed; ptyc-side
// fd is released either way (ptyc has exited by now). // fd is released either way (ptyc has exited by now).
if (childSock >= 0) libc.close(childSock); if (childSock >= 0) libc.close(childSock);
if (parentSock >= 0) libc.close(parentSock);
pkg_ffi.calloc.free(sv); pkg_ffi.calloc.free(sv);
} }
} }
/// Send bytes to the child's stdin. /// Send bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns total bytes
/// written, which equals [bytes.length] on success.
int write(List<int> bytes) { int write(List<int> bytes) {
if (isClosed) return 0; if (isClosed) return 0;
final buf = pkg_ffi.calloc<ffi.Uint8>(bytes.length); final buf = pkg_ffi.calloc<ffi.Uint8>(bytes.length);
@@ -169,7 +184,22 @@ class PtySession {
for (var i = 0; i < bytes.length; i++) { for (var i = 0; i < bytes.length; i++) {
buf[i] = bytes[i]; buf[i] = bytes[i];
} }
return libc.write(_masterFd, buf, bytes.length); var written = 0;
while (written < bytes.length) {
final n = libc.write(
_masterFd,
buf.elementAt(written),
bytes.length - written,
);
if (n < 0) {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('write', 'write to PTY failed', errno: err);
}
if (n == 0) break;
written += n;
}
return written;
} finally { } finally {
pkg_ffi.calloc.free(buf); pkg_ffi.calloc.free(buf);
} }
@@ -237,12 +267,16 @@ class PtySession {
/// stall the calling isolate's event loop. /// stall the calling isolate's event loop.
static Future<int> _recvFdAsync(int socketFd) async { static Future<int> _recvFdAsync(int socketFd) async {
final port = ReceivePort(); final port = ReceivePort();
final iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort)); Isolate? iso;
final result = await port.first; try {
iso.kill(priority: Isolate.immediate); iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
port.close(); final result = await port.first;
if (result is int) return result; if (result is int) return result;
throw PtyException('recvFd', '$result'); throw PtyException('recvFd', '$result');
} finally {
iso?.kill(priority: Isolate.immediate);
port.close();
}
} }
static void _recvFdEntry(_RecvFdArgs args) { static void _recvFdEntry(_RecvFdArgs args) {
@@ -271,7 +305,19 @@ class PtySession {
Isolate.spawn<_ReaderArgs>( Isolate.spawn<_ReaderArgs>(
_readerEntrypoint, _readerEntrypoint,
_ReaderArgs(fd: _masterFd, sendPort: port.sendPort), _ReaderArgs(fd: _masterFd, sendPort: port.sendPort),
).then((iso) => _readerIsolate = iso); ).then(
(iso) => _readerIsolate = iso,
onError: (Object e) {
// Spawn failure leaves the session unable to ever produce
// output. Surface the error and mark the controller closed
// so consumers don't hang waiting on the stream.
if (!_outputCtrl.isClosed) {
_outputCtrl.addError(PtyException('reader-spawn', '$e'));
_outputCtrl.close();
}
if (!_readerExited.isCompleted) _readerExited.complete();
},
);
} }
// -- request builder ------------------------------------------------------ // -- request builder ------------------------------------------------------
@@ -370,7 +416,7 @@ class _ReaderArgs {
/// each chunk back to the main isolate as a `Uint8List`. Exits on /// each chunk back to the main isolate as a `Uint8List`. Exits on
/// EOF, close, or error. /// EOF, close, or error.
void _readerEntrypoint(_ReaderArgs args) { void _readerEntrypoint(_ReaderArgs args) {
const chunk = 4096; const chunk = 65536;
final buf = pkg_ffi.calloc<ffi.Uint8>(chunk); final buf = pkg_ffi.calloc<ffi.Uint8>(chunk);
try { try {
while (true) { while (true) {
+35
View File
@@ -0,0 +1,35 @@
clide terminal — derivative work based on xterm.dart v4.0.0.
The terminal emulator code in this directory is derived from xterm.dart
(https://github.com/TerminalStudio/xterm.dart) and has been modified.
Changes include: Scrollable removal, quiver dependency replaced with
hand-rolled LRU, zmodem/debugger/suggestion modules stripped, scroll
event forwarding rewritten, imports restructured.
The original xterm.dart code is copyright (c) 2020 xuty and licensed
under the MIT License, reproduced below. This derivative work is part
of clide and is distributed under clide's MIT license.
------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2020 xuty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/base/event.dart';
mixin Disposable {
final _disposables = <Disposable>[];
bool get disposed => _disposed;
bool _disposed = false;
Event get onDisposed => _onDisposed.event;
final _onDisposed = EventEmitter();
void register(Disposable disposable) {
assert(!_disposed);
_disposables.add(disposable);
}
void registerCallback(void Function() callback) {
assert(!_disposed);
_disposables.add(_DisposeCallback(callback));
}
void dispose() {
_disposed = true;
for (final disposable in _disposables) {
disposable.dispose();
}
_onDisposed.emit(null);
}
}
class _DisposeCallback with Disposable {
final void Function() callback;
_DisposeCallback(this.callback);
@override
void dispose() {
super.dispose();
callback();
}
}
+44
View File
@@ -0,0 +1,44 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/base/disposable.dart';
typedef EventListener<T> = void Function(T event);
class Event<T> {
final EventEmitter<T> emitter;
Event(this.emitter);
void call(EventListener<T> listener) {
emitter(listener);
}
}
class EventEmitter<T> {
final _listeners = <EventListener<T>>[];
EventSubscription<T> call(EventListener<T> listener) {
_listeners.add(listener);
return EventSubscription(this, listener);
}
void emit(T event) {
for (final listener in _listeners) {
listener(event);
}
}
Event<T> get event => Event(this);
}
class EventSubscription<T> with Disposable {
final EventEmitter<T> emitter;
final EventListener<T> listener;
EventSubscription(this.emitter, this.listener);
@override
void dispose() {
emitter._listeners.remove(listener);
}
}
+19
View File
@@ -0,0 +1,19 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
mixin Observable {
final listeners = <void Function()>{};
void addListener(void Function() listener) {
listeners.add(listener);
}
void removeListener(void Function() listener) {
listeners.remove(listener);
}
void notifyListeners() {
for (var listener in listeners) {
listener();
}
}
}
@@ -0,0 +1,606 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'dart:math' show max, min;
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/line.dart';
import 'package:clide/src/terminal/src/core/buffer/range_line.dart';
import 'package:clide/src/terminal/src/core/buffer/range.dart';
import 'package:clide/src/terminal/src/core/charset.dart';
import 'package:clide/src/terminal/src/core/cursor.dart';
import 'package:clide/src/terminal/src/core/reflow.dart';
import 'package:clide/src/terminal/src/core/state.dart';
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
import 'package:clide/src/terminal/src/utils/unicode_v11.dart';
class Buffer {
final TerminalState terminal;
final int maxLines;
final bool isAltBuffer;
/// Characters that break selection when calling [getWordBoundary]. If null,
/// defaults to [defaultWordSeparators].
final Set<int>? wordSeparators;
Buffer(
this.terminal, {
required this.maxLines,
required this.isAltBuffer,
this.wordSeparators,
}) {
for (int i = 0; i < terminal.viewHeight; i++) {
lines.push(_newEmptyLine());
}
resetVerticalMargins();
}
int _cursorX = 0;
int _cursorY = 0;
late int _marginTop;
late int _marginBottom;
var _savedCursorX = 0;
var _savedCursorY = 0;
final _savedCursorStyle = CursorStyle();
final charset = Charset();
/// Width of the viewport in columns. Also the index of the last column.
int get viewWidth => terminal.viewWidth;
/// Height of the viewport in rows. Also the index of the last line.
int get viewHeight => terminal.viewHeight;
/// lines of the buffer. the length of [lines] should always be equal or
/// greater than [viewHeight].
late final lines = IndexAwareCircularBuffer<BufferLine>(maxLines);
/// Total number of lines in the buffer. Always equal or greater than
/// [viewHeight].
int get height => lines.length;
/// Horizontal position of the cursor relative to the top-left cornor of the
/// screen, starting from 0.
int get cursorX => _cursorX.clamp(0, terminal.viewWidth - 1);
/// Vertical position of the cursor relative to the top-left cornor of the
/// screen, starting from 0.
int get cursorY => _cursorY;
/// Index of the first line in the scroll region.
int get marginTop => _marginTop;
/// Index of the last line in the scroll region.
int get marginBottom => _marginBottom;
/// The number of lines above the viewport.
int get scrollBack => height - viewHeight;
/// Vertical position of the cursor relative to the top of the buffer,
/// starting from 0.
int get absoluteCursorY => _cursorY + scrollBack;
/// Absolute index of the first line in the scroll region.
int get absoluteMarginTop => _marginTop + scrollBack;
/// Absolute index of the last line in the scroll region.
int get absoluteMarginBottom => _marginBottom + scrollBack;
/// Writes data to the _terminal. Terminal sequences or special characters are
/// not interpreted and directly added to the buffer.
///
/// See also: [Terminal.write]
void write(String text) {
for (var char in text.runes) {
writeChar(char);
}
}
/// Writes a single character to the _terminal. Escape sequences or special
/// characters are not interpreted and directly added to the buffer.
///
/// See also: [Terminal.writeChar]
void writeChar(int codePoint) {
codePoint = charset.translate(codePoint);
final cellWidth = unicodeV11.wcwidth(codePoint);
if (_cursorX >= terminal.viewWidth) {
index();
setCursorX(0);
if (terminal.autoWrapMode) {
currentLine.isWrapped = true;
}
}
final line = currentLine;
line.setCell(_cursorX, codePoint, cellWidth, terminal.cursor);
if (_cursorX < viewWidth) {
_cursorX++;
}
if (cellWidth == 2) {
writeChar(0);
}
}
/// The line at the current cursor position.
BufferLine get currentLine {
return lines[absoluteCursorY];
}
void backspace() {
if (_cursorX == 0 && currentLine.isWrapped) {
currentLine.isWrapped = false;
moveCursor(viewWidth - 1, -1);
} else if (_cursorX == viewWidth) {
moveCursor(-2, 0);
} else {
moveCursor(-1, 0);
}
}
/// Erases the viewport from the cursor position to the end of the buffer,
/// including the cursor position.
void eraseDisplayFromCursor() {
eraseLineFromCursor();
for (var i = absoluteCursorY + 1; i < height; i++) {
final line = lines[i];
line.isWrapped = false;
line.eraseRange(0, viewWidth, terminal.cursor);
}
}
/// Erases the viewport from the top-left corner to the cursor, including the
/// cursor.
void eraseDisplayToCursor() {
eraseLineToCursor();
for (var i = 0; i < _cursorY; i++) {
final line = lines[i + scrollBack];
line.isWrapped = false;
line.eraseRange(0, viewWidth, terminal.cursor);
}
}
/// Erases the whole viewport.
void eraseDisplay() {
for (var i = 0; i < viewHeight; i++) {
final line = lines[i + scrollBack];
line.isWrapped = false;
line.eraseRange(0, viewWidth, terminal.cursor);
}
}
/// Erases the line from the cursor to the end of the line, including the
/// cursor position.
void eraseLineFromCursor() {
currentLine.isWrapped = false;
currentLine.eraseRange(_cursorX, viewWidth, terminal.cursor);
}
/// Erases the line from the start of the line to the cursor, including the
/// cursor.
void eraseLineToCursor() {
currentLine.isWrapped = false;
currentLine.eraseRange(0, _cursorX, terminal.cursor);
}
/// Erases the line at the current cursor position.
void eraseLine() {
currentLine.isWrapped = false;
currentLine.eraseRange(0, viewWidth, terminal.cursor);
}
/// Erases [count] cells starting at the cursor position.
void eraseChars(int count) {
final start = _cursorX;
currentLine.eraseRange(start, start + count, terminal.cursor);
}
void scrollDown(int lines) {
for (var i = absoluteMarginBottom; i >= absoluteMarginTop; i--) {
if (i >= absoluteMarginTop + lines) {
this.lines[i] = this.lines[i - lines];
} else {
this.lines[i] = _newEmptyLine();
}
}
}
void scrollUp(int lines) {
for (var i = absoluteMarginTop; i <= absoluteMarginBottom; i++) {
if (i <= absoluteMarginBottom - lines) {
this.lines[i] = this.lines[i + lines];
} else {
this.lines[i] = _newEmptyLine();
}
}
}
/// https://vt100.net/docs/vt100-ug/chapter3.html#IND IND – Index
///
/// ESC D
///
/// [index] causes the active position to move downward one line without
/// changing the column position. If the active position is at the bottom
/// margin, a scroll up is performed.
void index() {
if (isInVerticalMargin) {
if (_cursorY == _marginBottom) {
if (marginTop == 0 && !isAltBuffer) {
lines.insert(absoluteMarginBottom + 1, _newEmptyLine());
} else {
scrollUp(1);
}
} else {
moveCursorY(1);
}
return;
}
// the cursor is not in the scrollable region
if (_cursorY >= viewHeight - 1) {
// we are at the bottom
if (isAltBuffer) {
scrollUp(1);
} else {
lines.push(_newEmptyLine());
}
} else {
// there're still lines so we simply move cursor down.
moveCursorY(1);
}
}
void lineFeed() {
index();
if (terminal.lineFeedMode) {
setCursorX(0);
}
}
/// https://terminalguide.namepad.de/seq/a_esc_cm/
void reverseIndex() {
if (isInVerticalMargin) {
if (_cursorY == _marginTop) {
scrollDown(1);
} else {
moveCursorY(-1);
}
} else {
moveCursorY(-1);
}
}
void cursorGoForward() {
_cursorX = min(_cursorX + 1, viewWidth);
}
void setCursorX(int cursorX) {
_cursorX = cursorX.clamp(0, viewWidth - 1);
}
void setCursorY(int cursorY) {
_cursorY = cursorY.clamp(0, viewHeight - 1);
}
void moveCursorX(int offset) {
setCursorX(_cursorX + offset);
}
void moveCursorY(int offset) {
setCursorY(_cursorY + offset);
}
void setCursor(int cursorX, int cursorY) {
var maxCursorY = viewHeight - 1;
if (terminal.originMode) {
cursorY += _marginTop;
maxCursorY = _marginBottom;
}
_cursorX = cursorX.clamp(0, viewWidth - 1);
_cursorY = cursorY.clamp(0, maxCursorY);
}
void moveCursor(int offsetX, int offsetY) {
final cursorX = _cursorX + offsetX;
final cursorY = _cursorY + offsetY;
setCursor(cursorX, cursorY);
}
/// Save cursor position, charmap and text attributes.
void saveCursor() {
_savedCursorX = _cursorX;
_savedCursorY = _cursorY;
_savedCursorStyle.foreground = terminal.cursor.foreground;
_savedCursorStyle.background = terminal.cursor.background;
_savedCursorStyle.attrs = terminal.cursor.attrs;
charset.save();
}
/// Restore cursor position, charmap and text attributes.
void restoreCursor() {
_cursorX = _savedCursorX;
_cursorY = _savedCursorY;
terminal.cursor.foreground = _savedCursorStyle.foreground;
terminal.cursor.background = _savedCursorStyle.background;
terminal.cursor.attrs = _savedCursorStyle.attrs;
charset.restore();
}
/// Sets the vertical scrolling margin to [top] and [bottom].
/// Both values must be between 0 and [viewHeight] - 1.
void setVerticalMargins(int top, int bottom) {
_marginTop = top.clamp(0, viewHeight - 1);
_marginBottom = bottom.clamp(0, viewHeight - 1);
_marginTop = min(_marginTop, _marginBottom);
_marginBottom = max(_marginTop, _marginBottom);
}
bool get isInVerticalMargin {
return _cursorY >= _marginTop && _cursorY <= _marginBottom;
}
void resetVerticalMargins() {
setVerticalMargins(0, viewHeight - 1);
}
void deleteChars(int count) {
final start = _cursorX.clamp(0, viewWidth);
count = min(count, viewWidth - start);
currentLine.removeCells(start, count, terminal.cursor);
}
/// Remove all lines above the top of the viewport.
void clearScrollback() {
if (height <= viewHeight) {
return;
}
lines.trimStart(scrollBack);
}
/// Clears the viewport and scrollback buffer. Then fill with empty lines.
void clear() {
lines.clear();
for (int i = 0; i < viewHeight; i++) {
lines.push(_newEmptyLine());
}
}
void insertBlankChars(int count) {
currentLine.insertCells(_cursorX, count, terminal.cursor);
}
void insertLines(int count) {
if (!isInVerticalMargin) {
return;
}
setCursorX(0);
// Number of lines from the cursor to the bottom of the scrollable region
// including the cursor itself.
final linesBelow = absoluteMarginBottom - absoluteCursorY + 1;
// Number of empty lines to insert.
final linesToInsert = min(count, linesBelow);
// Number of lines to move up.
final linesToMove = linesBelow - linesToInsert;
for (var i = 0; i < linesToMove; i++) {
final index = absoluteMarginBottom - i;
lines[index] = lines.swap(index - linesToInsert, _newEmptyLine());
}
for (var i = linesToMove; i < linesToInsert; i++) {
lines[absoluteCursorY + i] = _newEmptyLine();
}
}
/// Remove [count] lines starting at the current cursor position. Lines below
/// the removed lines are shifted up. This only affects the scrollable region.
/// Lines outside the scrollable region are not affected.
void deleteLines(int count) {
if (!isInVerticalMargin) {
return;
}
setCursorX(0);
count = min(count, absoluteMarginBottom - absoluteCursorY + 1);
final linesToMove = absoluteMarginBottom - absoluteCursorY + 1 - count;
for (var i = 0; i < linesToMove; i++) {
final index = absoluteCursorY + i;
lines[index] = lines[index + count];
}
for (var i = 0; i < count; i++) {
lines[absoluteMarginBottom - i] = _newEmptyLine();
}
}
void resize(int oldWidth, int oldHeight, int newWidth, int newHeight) {
// 1. Adjust the height.
if (newHeight > oldHeight) {
// Grow larger
for (var i = 0; i < newHeight - oldHeight; i++) {
if (newHeight > lines.length) {
lines.push(_newEmptyLine(newWidth));
} else {
_cursorY++;
}
}
} else {
// Shrink smaller
for (var i = 0; i < oldHeight - newHeight; i++) {
if (_cursorY > newHeight - 1) {
_cursorY--;
} else {
lines.pop();
}
}
}
// Ensure cursor is within the screen.
_cursorX = _cursorX.clamp(0, newWidth - 1);
_cursorY = _cursorY.clamp(0, newHeight - 1);
// 2. Adjust the width.
if (newWidth != oldWidth) {
if (terminal.reflowEnabled && !isAltBuffer) {
final reflowResult = reflow(lines, oldWidth, newWidth);
while (reflowResult.length < newHeight) {
reflowResult.add(_newEmptyLine(newWidth));
}
lines.replaceWith(reflowResult);
} else {
lines.forEach((item) => item.resize(newWidth));
}
}
}
/// Create a new [CellAnchor] at the specified [x] and [y] coordinates.
CellAnchor createAnchor(int x, int y) {
return lines[y].createAnchor(x);
}
/// Create a new [CellAnchor] at the specified [x] and [y] coordinates.
CellAnchor createAnchorFromOffset(CellOffset offset) {
return lines[offset.y].createAnchor(offset.x);
}
CellAnchor createAnchorFromCursor() {
return createAnchor(cursorX, absoluteCursorY);
}
/// Create a new empty [BufferLine] with the current [viewWidth] if [width]
/// is not specified.
BufferLine _newEmptyLine([int? width]) {
final line = BufferLine(width ?? viewWidth);
return line;
}
static final defaultWordSeparators = <int>{
0,
r' '.codeUnitAt(0),
r'.'.codeUnitAt(0),
r':'.codeUnitAt(0),
r'-'.codeUnitAt(0),
r'\'.codeUnitAt(0),
r'"'.codeUnitAt(0),
r'*'.codeUnitAt(0),
r'+'.codeUnitAt(0),
r'/'.codeUnitAt(0),
r'\'.codeUnitAt(0),
};
BufferRangeLine? getWordBoundary(CellOffset position) {
var separators = wordSeparators ?? defaultWordSeparators;
if (position.y >= lines.length) {
return null;
}
var line = lines[position.y];
var start = position.x;
var end = position.x;
do {
if (start == 0) {
break;
}
final char = line.getCodePoint(start - 1);
if (separators.contains(char)) {
break;
}
start--;
} while (true);
do {
if (end >= viewWidth) {
break;
}
final char = line.getCodePoint(end);
if (separators.contains(char)) {
break;
}
end++;
} while (true);
if (start == end) {
return null;
}
return BufferRangeLine(
CellOffset(start, position.y),
CellOffset(end, position.y),
);
}
/// Get the plain text content of the buffer including the scrollback.
/// Accepts an optional [range] to get a specific part of the buffer.
String getText([BufferRange? range]) {
range ??= BufferRangeLine(
CellOffset(0, 0),
CellOffset(viewWidth - 1, height - 1),
);
range = range.normalized;
final builder = StringBuffer();
for (var segment in range.toSegments()) {
if (segment.line < 0 || segment.line >= height) {
continue;
}
final line = lines[segment.line];
if (!(segment.line == range.begin.y ||
segment.line == 0 ||
line.isWrapped)) {
builder.write("\n");
}
builder.write(line.getText(segment.start, segment.end));
}
return builder.toString();
}
/// Returns a debug representation of the buffer.
@override
String toString() {
final builder = StringBuffer();
final lineNumberLength = lines.length.toString().length;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
builder.write('${i.toString().padLeft(lineNumberLength)}: |${lines[i]}|');
if (line.isWrapped) {
builder.write(' (⏎)');
}
builder.write('\n');
}
return builder.toString();
}
}
@@ -0,0 +1,11 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
abstract class CellFlags {
static const bold = 1 << 0;
static const faint = 1 << 1;
static const italic = 1 << 2;
static const underline = 1 << 3;
static const blink = 1 << 4;
static const inverse = 1 << 5;
static const invisible = 1 << 6;
}
@@ -0,0 +1,57 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/buffer/range.dart';
class CellOffset {
final int x;
final int y;
const CellOffset(this.x, this.y);
bool isEqual(CellOffset other) {
return other.x == x && other.y == y;
}
bool isBefore(CellOffset other) {
return y < other.y || (y == other.y && x < other.x);
}
bool isAfter(CellOffset other) {
return y > other.y || (y == other.y && x > other.x);
}
bool isBeforeOrSame(CellOffset other) {
return y < other.y || (y == other.y && x <= other.x);
}
bool isAfterOrSame(CellOffset other) {
return y > other.y || (y == other.y && x >= other.x);
}
bool isAtSameRow(CellOffset other) {
return y == other.y;
}
bool isAtSameColumn(CellOffset other) {
return x == other.x;
}
bool isWithin(BufferRange range) {
return range.contains(this);
}
@override
String toString() => 'CellOffset($x, $y)';
@override
int get hashCode => x.hashCode ^ y.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CellOffset &&
runtimeType == other.runtimeType &&
x == other.x &&
y == other.y;
}
+420
View File
@@ -0,0 +1,420 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'dart:math' show min;
import 'dart:typed_data';
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/cell.dart';
import 'package:clide/src/terminal/src/core/cursor.dart';
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
import 'package:clide/src/terminal/src/utils/unicode_v11.dart';
const _cellSize = 4;
const _cellForeground = 0;
const _cellBackground = 1;
const _cellAttributes = 2;
const _cellContent = 3;
class BufferLine with IndexedItem {
BufferLine(
this._length, {
this.isWrapped = false,
}) : _data = Uint32List(_calcCapacity(_length) * _cellSize);
int _length;
Uint32List _data;
Uint32List get data => _data;
var isWrapped = false;
int get length => _length;
final _anchors = <CellAnchor>[];
List<CellAnchor> get anchors => _anchors;
int getForeground(int index) {
return _data[index * _cellSize + _cellForeground];
}
int getBackground(int index) {
return _data[index * _cellSize + _cellBackground];
}
int getAttributes(int index) {
return _data[index * _cellSize + _cellAttributes];
}
int getContent(int index) {
return _data[index * _cellSize + _cellContent];
}
int getCodePoint(int index) {
return _data[index * _cellSize + _cellContent] & CellContent.codepointMask;
}
int getWidth(int index) {
return _data[index * _cellSize + _cellContent] >> CellContent.widthShift;
}
void getCellData(int index, CellData cellData) {
final offset = index * _cellSize;
cellData.foreground = _data[offset + _cellForeground];
cellData.background = _data[offset + _cellBackground];
cellData.flags = _data[offset + _cellAttributes];
cellData.content = _data[offset + _cellContent];
}
CellData createCellData(int index) {
final cellData = CellData.empty();
final offset = index * _cellSize;
_data[offset + _cellForeground] = cellData.foreground;
_data[offset + _cellBackground] = cellData.background;
_data[offset + _cellAttributes] = cellData.flags;
_data[offset + _cellContent] = cellData.content;
return cellData;
}
void setForeground(int index, int value) {
_data[index * _cellSize + _cellForeground] = value;
}
void setBackground(int index, int value) {
_data[index * _cellSize + _cellBackground] = value;
}
void setAttributes(int index, int value) {
_data[index * _cellSize + _cellAttributes] = value;
}
void setContent(int index, int value) {
_data[index * _cellSize + _cellContent] = value;
}
void setCodePoint(int index, int char) {
final width = unicodeV11.wcwidth(char);
setContent(index, char | (width << CellContent.widthShift));
}
void setCell(int index, int char, int witdh, CursorStyle style) {
final offset = index * _cellSize;
_data[offset + _cellForeground] = style.foreground;
_data[offset + _cellBackground] = style.background;
_data[offset + _cellAttributes] = style.attrs;
_data[offset + _cellContent] = char | (witdh << CellContent.widthShift);
}
void setCellData(int index, CellData cellData) {
final offset = index * _cellSize;
_data[offset + _cellForeground] = cellData.foreground;
_data[offset + _cellBackground] = cellData.background;
_data[offset + _cellAttributes] = cellData.flags;
_data[offset + _cellContent] = cellData.content;
}
void eraseCell(int index, CursorStyle style) {
final offset = index * _cellSize;
_data[offset + _cellForeground] = style.foreground;
_data[offset + _cellBackground] = style.background;
_data[offset + _cellAttributes] = style.attrs;
_data[offset + _cellContent] = 0;
}
void resetCell(int index) {
final offset = index * _cellSize;
_data[offset + _cellForeground] = 0;
_data[offset + _cellBackground] = 0;
_data[offset + _cellAttributes] = 0;
_data[offset + _cellContent] = 0;
}
/// Erase cells whose index satisfies [start] <= index < [end]. Erased cells
/// are filled with [style].
void eraseRange(int start, int end, CursorStyle style) {
// reset cell one to the left if start is second cell of a wide char
if (start > 0 && getWidth(start - 1) == 2) {
eraseCell(start - 1, style);
}
// reset cell one to the right if end is second cell of a wide char
if (end < _length && getWidth(end - 1) == 2) {
eraseCell(end - 1, style);
}
end = min(end, _length);
for (var i = start; i < end; i++) {
eraseCell(i, style);
}
}
/// Remove [count] cells starting at [start]. Cells that are empty after the
/// removal are filled with [style].
void removeCells(int start, int count, [CursorStyle? style]) {
assert(start >= 0 && start < _length);
assert(count >= 0 && start + count <= _length);
style ??= CursorStyle.empty;
if (start + count < _length) {
final moveStart = start * _cellSize;
final moveEnd = (_length - count) * _cellSize;
final moveOffset = count * _cellSize;
for (var i = moveStart; i < moveEnd; i++) {
_data[i] = _data[i + moveOffset];
}
}
for (var i = _length - count; i < _length; i++) {
eraseCell(i, style);
}
if (start > 0 && getWidth(start - 1) == 2) {
eraseCell(start - 1, style);
}
// Update anchors, remove anchors that are inside the removed range.
for (var i = 0; i < _anchors.length; i++) {
final anchor = _anchors[i];
if (anchor.x >= start) {
if (anchor.x < start + count) {
anchor.dispose();
} else {
anchor.reposition(anchor.x - count);
}
}
}
}
/// Inserts [count] cells at [start]. New cells are initialized with [style].
void insertCells(int start, int count, [CursorStyle? style]) {
style ??= CursorStyle.empty;
if (start > 0 && getWidth(start - 1) == 2) {
eraseCell(start - 1, style);
}
if (start + count < _length) {
final moveStart = start * _cellSize;
final moveEnd = (_length - count) * _cellSize;
final moveOffset = count * _cellSize;
for (var i = moveEnd - 1; i >= moveStart; i--) {
_data[i + moveOffset] = _data[i];
}
}
final end = min(start + count, _length);
for (var i = start; i < end; i++) {
eraseCell(i, style);
}
if (getWidth(_length - 1) == 2) {
eraseCell(_length - 1, style);
}
// Update anchors, move anchors that are after the inserted range.
for (var i = 0; i < _anchors.length; i++) {
final anchor = _anchors[i];
if (anchor.x >= start + count) {
anchor.reposition(anchor.x + count);
// Remove anchors that are now outside the buffer.
if (anchor.x >= _length) {
anchor.dispose();
}
}
}
}
void resize(int length) {
assert(length >= 0);
if (length == _length) {
return;
}
if (length > _length) {
final newBufferSize = _calcCapacity(length) * _cellSize;
if (newBufferSize > _data.length) {
final newBuffer = Uint32List(newBufferSize);
newBuffer.setRange(0, _data.length, _data);
_data = newBuffer;
}
}
_length = length;
for (var i = 0; i < _anchors.length; i++) {
final anchor = _anchors[i];
if (anchor.x > _length) {
anchor.reposition(_length);
}
}
}
/// Returns the offset of the last cell that has content from the start of
/// the line.
int getTrimmedLength([int? cols]) {
final maxCols = _data.length ~/ _cellSize;
if (cols == null || cols > maxCols) {
cols = maxCols;
}
if (cols <= 0) {
return 0;
}
for (var i = cols - 1; i >= 0; i--) {
var codePoint = getCodePoint(i);
if (codePoint != 0) {
// we are at the last cell in this line that has content.
// the length of this line is the index of this cell + 1
// the only exception is that if that last cell is wider
// than 1 then we have to add the diff
final lastCellWidth = getWidth(i);
return i + lastCellWidth;
}
}
return 0;
}
/// Copies [len] cells from [src] starting at [srcCol] to [dstCol] at this
/// line.
void copyFrom(BufferLine src, int srcCol, int dstCol, int len) {
resize(dstCol + len);
// data.setRange(
// dstCol * _cellSize,
// (dstCol + len) * _cellSize,
// Uint32List.sublistView(src.data, srcCol * _cellSize, len * _cellSize),
// );
var srcOffset = srcCol * _cellSize;
var dstOffset = dstCol * _cellSize;
for (var i = 0; i < len * _cellSize; i++) {
_data[dstOffset++] = src._data[srcOffset++];
}
}
static int _calcCapacity(int length) {
assert(length >= 0);
var capacity = 64;
if (length < 256) {
while (capacity < length) {
capacity *= 2;
}
} else {
capacity = 256;
while (capacity < length) {
capacity += 32;
}
}
return capacity;
}
String getText([int? from, int? to]) {
if (from == null || from < 0) {
from = 0;
}
if (to == null || to > _length) {
to = _length;
}
final builder = StringBuffer();
for (var i = from; i < to; i++) {
final codePoint = getCodePoint(i);
final width = getWidth(i);
if (codePoint != 0 && i + width <= to) {
builder.writeCharCode(codePoint);
}
}
return builder.toString();
}
CellAnchor createAnchor(int offset) {
final anchor = CellAnchor(offset, owner: this);
_anchors.add(anchor);
return anchor;
}
void dispose() {
for (final anchor in _anchors) {
anchor.dispose();
}
}
@override
String toString() {
return getText();
}
}
/// A handle to a cell in a [BufferLine] that can be used to track the location
/// of the cell. Anchors are guaranteed to be stable, retaining their relative
/// position to each other after mutations to the buffer.
class CellAnchor {
CellAnchor(int offset, {BufferLine? owner})
: _offset = offset,
_owner = owner;
int _offset;
int get x {
return _offset;
}
int get y {
assert(attached);
return _owner!.index;
}
CellOffset get offset {
assert(attached);
return CellOffset(_offset, _owner!.index);
}
BufferLine? _owner;
BufferLine? get line => _owner;
bool get attached => _owner?.attached ?? false;
void reparent(BufferLine owner, int offset) {
_owner?._anchors.remove(this);
_owner = owner;
_owner?._anchors.add(this);
_offset = offset;
}
void reposition(int offset) {
_offset = offset;
}
void dispose() {
_owner?._anchors.remove(this);
_owner = null;
}
@override
String toString() {
if (attached) {
return 'CellAnchor($x, $y)';
} else {
return 'CellAnchor($x, detached)';
}
}
}
@@ -0,0 +1,57 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
abstract class BufferRange {
final CellOffset begin;
final CellOffset end;
const BufferRange(this.begin, this.end);
BufferRange.collapsed(this.begin) : end = begin;
bool get isNormalized {
return begin.isBefore(end) || begin.isEqual(end);
}
bool get isCollapsed {
return begin.isEqual(end);
}
BufferRange get normalized;
/// Convert this range to segments of single lines.
Iterable<BufferSegment> toSegments();
/// Returns true if the given[position] is within this range.
bool contains(CellOffset position);
/// Returns the smallest range that contains both this range and the given
/// [range].
BufferRange merge(BufferRange range);
/// Returns the smallest range that contains both this range and the given
/// [position].
BufferRange extend(CellOffset position);
@override
operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! BufferRange) {
return false;
}
return begin == other.begin && end == other.end;
}
@override
int get hashCode => begin.hashCode ^ end.hashCode;
@override
String toString() => 'Range($begin, $end)';
}
@@ -0,0 +1,113 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'dart:math';
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/range.dart';
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
class BufferRangeBlock extends BufferRange {
BufferRangeBlock(super.begin, super.end);
BufferRangeBlock.collapsed(super.begin) : super.collapsed();
@override
bool get isNormalized {
// A block range is normalized if begin is the top left corner of the range
// and end the bottom right corner.
return (begin.isBefore(end) && begin.x <= end.x) || begin.isEqual(end);
}
@override
BufferRangeBlock get normalized {
if (isNormalized) {
return this;
}
// Determine new normalized begin and end offset, such that begin is the
// top left corner and end is the bottom right corner of the block.
final normalBegin = CellOffset(min(begin.x, end.x), min(begin.y, end.y));
final normalEnd = CellOffset(max(begin.x, end.x), max(begin.y, end.y));
return BufferRangeBlock(normalBegin, normalEnd);
}
@override
Iterable<BufferSegment> toSegments() sync* {
var begin = this.begin;
var end = this.end;
if (!isNormalized) {
end = this.begin;
begin = this.end;
}
final startX = min(begin.x, end.x);
final endX = max(begin.x, end.x);
for (var i = begin.y; i <= end.y; i++) {
yield BufferSegment(this, i, startX, endX);
}
}
@override
bool contains(CellOffset position) {
var begin = this.begin;
var end = this.end;
if (!isNormalized) {
end = this.begin;
begin = this.end;
}
if (!(begin.y <= position.y && position.y <= end.y)) {
return false;
}
final startX = min(begin.x, end.x);
final endX = max(begin.x, end.x);
return startX <= position.x && position.x <= endX;
}
@override
BufferRangeBlock merge(BufferRange range) {
// Enlarge the block such that both borders of the range
// are within the selected block.
return extend(range.begin).extend(range.end);
}
@override
BufferRangeBlock extend(CellOffset position) {
// If the position is within the block, there is nothing to do.
if (contains(position)) {
return this;
}
// Otherwise normalize the block and push the borders outside up to
// the position to which the block has to extended.
final normal = normalized;
final extendBegin = CellOffset(
min(normal.begin.x, position.x),
min(normal.begin.y, position.y),
);
final extendEnd = CellOffset(
max(normal.end.x, position.x),
max(normal.end.y, position.y),
);
return BufferRangeBlock(extendBegin, extendEnd);
}
@override
operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! BufferRangeBlock) {
return false;
}
return begin == other.begin && end == other.end;
}
@override
int get hashCode => begin.hashCode ^ end.hashCode;
@override
String toString() => 'Block Range($begin, $end)';
}
@@ -0,0 +1,68 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/range.dart';
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
class BufferRangeLine extends BufferRange {
BufferRangeLine(super.begin, super.end);
BufferRangeLine.collapsed(super.begin) : super.collapsed();
@override
BufferRangeLine get normalized {
return isNormalized ? this : BufferRangeLine(end, begin);
}
@override
Iterable<BufferSegment> toSegments() sync* {
final self = normalized;
for (var i = self.begin.y; i <= self.end.y; i++) {
var startX = i == self.begin.y ? self.begin.x : null;
var endX = i == self.end.y ? self.end.x : null;
yield BufferSegment(this, i, startX, endX);
}
}
@override
bool contains(CellOffset position) {
final self = normalized;
return self.begin.isBeforeOrSame(position) &&
self.end.isAfterOrSame(position);
}
@override
BufferRangeLine merge(BufferRange range) {
final self = normalized;
final begin = self.begin.isBefore(range.begin) ? self.begin : range.begin;
final end = self.end.isAfter(range.end) ? self.end : range.end;
return BufferRangeLine(begin, end);
}
@override
BufferRangeLine extend(CellOffset position) {
final self = normalized;
final begin = self.begin.isAfter(position) ? position : self.begin;
final end = self.end.isBefore(position) ? position : self.end;
return BufferRangeLine(begin, end);
}
@override
operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! BufferRangeLine) {
return false;
}
return begin == other.begin && end == other.end;
}
@override
int get hashCode => begin.hashCode ^ end.hashCode;
@override
String toString() => 'Line Range($begin, $end)';
}
@@ -0,0 +1,60 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/range.dart';
/// A BufferSegment represents a range within a line.
class BufferSegment {
/// The range that this segment belongs to.
final BufferRange range;
/// The line that this segment resides on.
final int line;
/// The start position of this segment. [null] means the start of the line.
final int? start;
/// The end position of this segment. [null] means the end of the line.
/// Should be greater than or equal to [start].
final int? end;
const BufferSegment(this.range, this.line, this.start, this.end)
: assert((start != null && end != null) ? start <= end : true);
bool isWithin(CellOffset position) {
if (position.y != line) {
return false;
}
if (start != null && position.x < start!) {
return false;
}
if (end != null && position.x > end!) {
return false;
}
return true;
}
@override
String toString() {
final start = this.start != null ? this.start.toString() : 'start';
final end = this.end != null ? this.end.toString() : 'end';
return 'Segment($line, $start -> $end)';
}
@override
int get hashCode =>
range.hashCode ^ line.hashCode ^ start.hashCode ^ end.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BufferSegment &&
runtimeType == other.runtimeType &&
range == other.range &&
line == other.line &&
start == other.start &&
end == other.end;
}
+68
View File
@@ -0,0 +1,68 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/utils/hash_values.dart';
class CellData {
CellData({
required this.foreground,
required this.background,
required this.flags,
required this.content,
});
factory CellData.empty() {
return CellData(
foreground: 0,
background: 0,
flags: 0,
content: 0,
);
}
int foreground;
int background;
int flags;
int content;
int getHash() {
return hashValues(foreground, background, flags, content);
}
@override
String toString() {
return 'CellData{foreground: $foreground, background: $background, flags: $flags, content: $content}';
}
}
abstract class CellAttr {
static const bold = 1 << 0;
static const faint = 1 << 1;
static const italic = 1 << 2;
static const underline = 1 << 3;
static const blink = 1 << 4;
static const inverse = 1 << 5;
static const invisible = 1 << 6;
static const strikethrough = 1 << 7;
}
abstract class CellColor {
static const valueMask = 0xFFFFFF;
static const typeShift = 25;
static const typeMask = 3 << typeShift;
static const normal = 0 << typeShift;
static const named = 1 << typeShift;
static const palette = 2 << typeShift;
static const rgb = 3 << typeShift;
}
abstract class CellContent {
static const codepointMask = 0x1fffff;
static const widthShift = 22;
// static const widthMask = 3 << widthShift;
}
+97
View File
@@ -0,0 +1,97 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
typedef CharsetTranslator = int Function(int);
final _charsets = <int, CharsetTranslator>{
'0'.codeUnitAt(0): decSpecGraphicsTranslator,
'B'.codeUnitAt(0): asciiTranslator,
};
class Charset {
var _charsetMap = <int, CharsetTranslator>{};
var _currentIndex = 0;
var _savedCharsetMap = <int, CharsetTranslator>{};
var _savedIndex = 0;
var _cached = asciiTranslator;
void _updateCache() {
_cached = _charsetMap[_currentIndex] ?? asciiTranslator;
}
int translate(int codePoint) {
return _cached(codePoint);
}
void designate(int index, int name) {
final charset = _charsets[name];
if (charset != null) {
_charsetMap[index] = charset;
_updateCache();
}
}
void use(int index) {
_currentIndex = index;
_updateCache();
}
void save() {
_savedCharsetMap = Map.from(_charsetMap);
_savedIndex = _currentIndex;
}
void restore() {
_charsetMap = _savedCharsetMap;
_currentIndex = _savedIndex;
_updateCache();
}
}
const decSpecGraphics = <int, int>{
0x5f: 0x00A0, // NO-BREAK SPACE
0x60: 0x25C6, // BLACK DIAMOND
0x61: 0x2592, // MEDIUM SHADE
0x62: 0x2409, // SYMBOL FOR HORIZONTAL TABULATION
0x63: 0x240C, // SYMBOL FOR FORM FEED
0x64: 0x240D, // SYMBOL FOR CARRIAGE RETURN
0x65: 0x240A, // SYMBOL FOR LINE FEED
0x66: 0x00B0, // DEGREE SIGN
0x67: 0x00B1, // PLUS-MINUS SIGN
0x68: 0x2424, // SYMBOL FOR NEWLINE
0x69: 0x240B, // SYMBOL FOR VERTICAL TABULATION
0x6a: 0x2518, // BOX DRAWINGS LIGHT UP AND LEFT
0x6b: 0x2510, // BOX DRAWINGS LIGHT DOWN AND LEFT
0x6c: 0x250C, // BOX DRAWINGS LIGHT DOWN AND RIGHT
0x6d: 0x2514, // BOX DRAWINGS LIGHT UP AND RIGHT
0x6e: 0x253C, // BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
0x6f: 0x23BA, // HORIZONTAL SCAN LINE-1
0x70: 0x23BB, // HORIZONTAL SCAN LINE-3
0x71: 0x2500, // BOX DRAWINGS LIGHT HORIZONTAL
0x72: 0x23BC, // HORIZONTAL SCAN LINE-7
0x73: 0x23BD, // HORIZONTAL SCAN LINE-9
0x74: 0x251C, // BOX DRAWINGS LIGHT VERTICAL AND RIGHT
0x75: 0x2524, // BOX DRAWINGS LIGHT VERTICAL AND LEFT
0x76: 0x2534, // BOX DRAWINGS LIGHT UP AND HORIZONTAL
0x77: 0x252C, // BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
0x78: 0x2502, // BOX DRAWINGS LIGHT VERTICAL
0x79: 0x2264, // LESS-THAN OR EQUAL TO
0x7a: 0x2265, // GREATER-THAN OR EQUAL TO
0x7b: 0x03C0, // GREEK SMALL LETTER PI
0x7c: 0x2260, // NOT EQUAL TO
0x7d: 0x00A3, // POUND SIGN
0x7e: 0x00B7, // MIDDLE DOT
};
int asciiTranslator(int codePoint) {
return codePoint;
}
int decSpecGraphicsTranslator(int codePoint) {
if (codePoint >= 127) {
return codePoint;
}
return decSpecGraphics[codePoint] ?? codePoint;
}
+21
View File
@@ -0,0 +1,21 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
abstract class NamedColor {
static const black = 0;
static const red = 1;
static const green = 2;
static const yellow = 3;
static const blue = 4;
static const magenta = 5;
static const cyan = 6;
static const white = 7;
static const brightBlack = 8;
static const brightRed = 9;
static const brightGreen = 10;
static const brightYellow = 11;
static const brightBlue = 12;
static const brightMagenta = 13;
static const brightCyan = 14;
static const brightWhite = 15;
}
+139
View File
@@ -0,0 +1,139 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/cell.dart';
class CursorStyle {
int foreground;
int background;
int attrs;
CursorStyle({this.foreground = 0, this.background = 0, this.attrs = 0});
static final empty = CursorStyle();
void setBold() {
attrs |= CellAttr.bold;
}
void setFaint() {
attrs |= CellAttr.faint;
}
void setItalic() {
attrs |= CellAttr.italic;
}
void setUnderline() {
attrs |= CellAttr.underline;
}
void setBlink() {
attrs |= CellAttr.blink;
}
void setInverse() {
attrs |= CellAttr.inverse;
}
void setInvisible() {
attrs |= CellAttr.invisible;
}
void setStrikethrough() {
attrs |= CellAttr.strikethrough;
}
void unsetBold() {
attrs &= ~CellAttr.bold;
}
void unsetFaint() {
attrs &= ~CellAttr.faint;
}
void unsetItalic() {
attrs &= ~CellAttr.italic;
}
void unsetUnderline() {
attrs &= ~CellAttr.underline;
}
void unsetBlink() {
attrs &= ~CellAttr.blink;
}
void unsetInverse() {
attrs &= ~CellAttr.inverse;
}
void unsetInvisible() {
attrs &= ~CellAttr.invisible;
}
void unsetStrikethrough() {
attrs &= ~CellAttr.strikethrough;
}
bool get isBold => (attrs & CellAttr.bold) != 0;
bool get isFaint => (attrs & CellAttr.faint) != 0;
bool get isItalis => (attrs & CellAttr.italic) != 0;
bool get isUnderline => (attrs & CellAttr.underline) != 0;
bool get isBlink => (attrs & CellAttr.blink) != 0;
bool get isInverse => (attrs & CellAttr.inverse) != 0;
bool get isInvisible => (attrs & CellAttr.invisible) != 0;
void setForegroundColor16(int color) {
foreground = color | CellColor.named;
}
void setForegroundColor256(int color) {
foreground = color | CellColor.palette;
}
void setForegroundColorRgb(int r, int g, int b) {
foreground = (r << 16) | (g << 8) | b | CellColor.rgb;
}
void resetForegroundColor() {
foreground = 0; // | CellColor.normal;
}
void setBackgroundColor16(int color) {
background = color | CellColor.named;
}
void setBackgroundColor256(int color) {
background = color | CellColor.palette;
}
void setBackgroundColorRgb(int r, int g, int b) {
background = (r << 16) | (g << 8) | b | CellColor.rgb;
}
void resetBackgroundColor() {
background = 0; // | CellColor.normal;
}
void reset() {
foreground = 0;
background = 0;
attrs = 0;
}
}
class CursorPosition {
int x;
int y;
CursorPosition(this.x, this.y);
}
@@ -0,0 +1,35 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
class EscapeEmitter {
const EscapeEmitter();
String primaryDeviceAttributes() {
return '\x1b[?1;2c';
}
String secondaryDeviceAttributes() {
const model = 0;
const version = 0;
return '\x1b[>$model;$version;0c';
}
String tertiaryDeviceAttributes() {
return '\x1bP!|00000000\x1b\\';
}
String operatingStatus() {
return '\x1b[0n';
}
String cursorPosition(int x, int y) {
return '\x1b[$y;${x}R';
}
String bracketedPaste(String text) {
return '\x1b[200~$text\x1b[201~';
}
String size(int rows, int cols) {
return '\x1b[8;$rows;${cols}t';
}
}
@@ -0,0 +1,217 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
abstract class EscapeHandler {
void writeChar(int char);
/* SBC */
void bell();
void backspaceReturn();
void tab();
void lineFeed();
void carriageReturn();
void shiftOut();
void shiftIn();
void unknownSBC(int char);
/* ANSI sequence */
void saveCursor();
void restoreCursor();
void index();
void nextLine();
void setTapStop();
void reverseIndex();
void designateCharset(int charset, int name);
void unkownEscape(int char);
/* CSI */
void repeatPreviousCharacter(int n);
void setCursor(int x, int y);
void setCursorX(int x);
void setCursorY(int y);
void sendPrimaryDeviceAttributes();
void clearTabStopUnderCursor();
void clearAllTabStops();
void moveCursorX(int offset);
void moveCursorY(int n);
void sendSecondaryDeviceAttributes();
void sendTertiaryDeviceAttributes();
void sendOperatingStatus();
void sendCursorPosition();
void setMargins(int i, [int? bottom]);
void cursorNextLine(int amount);
void cursorPrecedingLine(int amount);
void eraseDisplayBelow();
void eraseDisplayAbove();
void eraseDisplay();
void eraseScrollbackOnly();
void eraseLineRight();
void eraseLineLeft();
void eraseLine();
void insertLines(int amount);
void deleteLines(int amount);
void deleteChars(int amount);
void scrollUp(int amount);
void scrollDown(int amount);
void eraseChars(int amount);
void insertBlankChars(int amount);
void unknownCSI(int finalByte);
/* Modes */
void setInsertMode(bool enabled);
void setLineFeedMode(bool enabled);
void setUnknownMode(int mode, bool enabled);
/* DEC Private modes */
void setCursorKeysMode(bool enabled);
void setReverseDisplayMode(bool enabled);
void setOriginMode(bool enabled);
void setColumnMode(bool enabled);
void setAutoWrapMode(bool enabled);
void setMouseMode(MouseMode mode);
void setCursorBlinkMode(bool enabled);
void setCursorVisibleMode(bool enabled);
void useAltBuffer();
void useMainBuffer();
void clearAltBuffer();
void setAppKeypadMode(bool enabled);
void setReportFocusMode(bool enabled);
void setMouseReportMode(MouseReportMode mode);
void setAltBufferMouseScrollMode(bool enabled);
void setBracketedPasteMode(bool enabled);
void setUnknownDecMode(int mode, bool enabled);
void resize(int cols, int rows);
void sendSize();
/* Select Graphic Rendition (SGR) */
void resetCursorStyle();
void setCursorBold();
void setCursorFaint();
void setCursorItalic();
void setCursorUnderline();
void setCursorBlink();
void setCursorInverse();
void setCursorInvisible();
void setCursorStrikethrough();
void unsetCursorBold();
void unsetCursorFaint();
void unsetCursorItalic();
void unsetCursorUnderline();
void unsetCursorBlink();
void unsetCursorInverse();
void unsetCursorInvisible();
void unsetCursorStrikethrough();
void setForegroundColor16(int color);
void setForegroundColor256(int index);
void setForegroundColorRgb(int r, int g, int b);
void resetForeground();
void setBackgroundColor16(int color);
void setBackgroundColor256(int index);
void setBackgroundColorRgb(int r, int g, int b);
void resetBackground();
void unsupportedStyle(int param);
/* OSC */
void setTitle(String name);
void setIconName(String name);
void unknownOSC(String code, List<String> args);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:clide/src/terminal/src/core/input/keys.dart';
import 'package:clide/src/terminal/src/core/input/keytab/keytab.dart';
import 'package:clide/src/terminal/src/core/state.dart';
import 'package:clide/src/terminal/src/core/platform.dart';
/// The key event received from the keyboard, along with the state of the
/// modifier keys and state of the terminal. Typically consumed by the
/// [TerminalInputHandler] to produce a escape sequence that can be recognized
/// by the terminal.
///
/// See also:
/// - [TerminalInputHandler]
class TerminalKeyboardEvent {
final TerminalKey key;
final bool shift;
final bool ctrl;
final bool alt;
final TerminalState state;
final bool altBuffer;
final TerminalTargetPlatform platform;
TerminalKeyboardEvent({
required this.key,
required this.shift,
required this.ctrl,
required this.alt,
required this.state,
required this.altBuffer,
required this.platform,
});
TerminalKeyboardEvent copyWith({
TerminalKey? key,
bool? shift,
bool? ctrl,
bool? alt,
TerminalState? state,
bool? altBuffer,
TerminalTargetPlatform? platform,
}) {
return TerminalKeyboardEvent(
key: key ?? this.key,
shift: shift ?? this.shift,
ctrl: ctrl ?? this.ctrl,
alt: alt ?? this.alt,
state: state ?? this.state,
altBuffer: altBuffer ?? this.altBuffer,
platform: platform ?? this.platform,
);
}
}
/// TerminalInputHandler contains the logic for translating a [TerminalKeyboardEvent]
/// into escape sequences that can be recognized by the terminal.
abstract class TerminalInputHandler {
/// Translates a [TerminalKeyboardEvent] into an escape sequence. If the event
/// cannot be translated, null is returned.
String? call(TerminalKeyboardEvent event);
}
/// A [TerminalInputHandler] that chains multiple handlers together. If any
/// handler returns a non-null value, it is returned. Otherwise, null is
/// returned.
class CascadeInputHandler implements TerminalInputHandler {
final List<TerminalInputHandler> _handlers;
const CascadeInputHandler(this._handlers);
@override
String? call(TerminalKeyboardEvent event) {
for (var handler in _handlers) {
final result = handler(event);
if (result != null) {
return result;
}
}
return null;
}
}
/// The default input handler for the terminal. That is composed of a
/// [KeytabInputHandler], a [CtrlInputHandler], and a [AltInputHandler].
///
/// It's possible to override the default input handler behavior by chaining
/// another input handler before or after the default input handler using
/// [CascadeInputHandler].
///
/// See also:
/// * [CascadeInputHandler]
const defaultInputHandler = CascadeInputHandler([
KeytabInputHandler(),
CtrlInputHandler(),
AltInputHandler(),
]);
/// A [TerminalInputHandler] that translates key events according to a keytab
/// file. If no keytab is provided, [Keytab.defaultKeytab] is used.
class KeytabInputHandler implements TerminalInputHandler {
const KeytabInputHandler([this.keytab]);
final Keytab? keytab;
@override
String? call(TerminalKeyboardEvent event) {
final keytab = this.keytab ?? Keytab.defaultKeytab;
final record = keytab.find(
event.key,
ctrl: event.ctrl,
alt: event.alt,
shift: event.shift,
newLineMode: event.state.lineFeedMode,
appCursorKeys: event.state.appKeypadMode,
appKeyPad: event.state.appKeypadMode,
appScreen: event.altBuffer,
macos: event.platform == TerminalTargetPlatform.macos,
);
if (record == null) {
return null;
}
var result = record.action.unescapedValue();
result = insertModifiers(event, result);
return result;
}
String insertModifiers(TerminalKeyboardEvent event, String action) {
String? code;
if (event.shift && event.alt && event.ctrl) {
code = '8';
} else if (event.ctrl && event.alt) {
code = '7';
} else if (event.shift && event.ctrl) {
code = '6';
} else if (event.ctrl) {
code = '5';
} else if (event.shift && event.alt) {
code = '4';
} else if (event.alt) {
code = '3';
} else if (event.shift) {
code = '2';
}
if (code != null) {
return action.replaceAll('*', code);
}
return action;
}
}
/// A [TerminalInputHandler] that translates ctrl + key events into escape
/// sequences. For example, ctrl + a becomes ^A.
class CtrlInputHandler implements TerminalInputHandler {
const CtrlInputHandler();
@override
String? call(TerminalKeyboardEvent event) {
if (!event.ctrl || event.shift || event.alt) {
return null;
}
final key = event.key;
if (key.index >= TerminalKey.keyA.index &&
key.index <= TerminalKey.keyZ.index) {
final input = key.index - TerminalKey.keyA.index + 1;
return String.fromCharCode(input);
}
return null;
}
}
/// A [TerminalInputHandler] that translates alt + key events into escape
/// sequences. For example, alt + a becomes ^[a.
class AltInputHandler implements TerminalInputHandler {
const AltInputHandler();
@override
String? call(TerminalKeyboardEvent event) {
if (!event.alt || event.ctrl || event.shift) {
return null;
}
if (event.platform == TerminalTargetPlatform.macos) {
return null;
}
final key = event.key;
if (key.index >= TerminalKey.keyA.index &&
key.index <= TerminalKey.keyZ.index) {
final charCode = key.index - TerminalKey.keyA.index + 65;
final input = [0x1b, charCode];
return String.fromCharCodes(input);
}
return null;
}
}

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