aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rwxr-xr-xdocs/demo-agents.sh67
-rw-r--r--docs/images/hero.pngbin0 -> 786071 bytes
-rw-r--r--docs/images/panel.pngbin0 -> 971029 bytes
-rw-r--r--docs/images/search.pngbin0 -> 401525 bytes
-rw-r--r--docs/images/statusline.pngbin0 -> 366796 bytes
-rw-r--r--docs/reference.md323
-rwxr-xr-xdocs/screenshots.sh198
7 files changed, 588 insertions, 0 deletions
diff --git a/docs/demo-agents.sh b/docs/demo-agents.sh
new file mode 100755
index 0000000..86f1436
--- /dev/null
+++ b/docs/demo-agents.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# A tmux server of made-up agents, for the README's screenshots: what the
+# daemon would write to pane options, written directly (no agents run).
+#
+# docs/demo-agents.sh <socket-name> <dir>
+#
+# Every pane runs a copy of `sleep`, in / (search matches directories too,
+# and a temporary one would match almost any query); nothing touches any
+# other tmux server.
+set -eu
+SOCK=$1
+DIR=$2
+t() { tmux -u -L "$SOCK" "$@"; }
+now=$(($(date +%s) * 1000))
+ago() { echo $((now - $1 * 60000)); }
+
+t kill-server 2>/dev/null || true
+mkdir -p "$DIR/bin"
+# Each pane runs a copy of sleep named after its agent, so process detection
+# (`dingger status`) finds it.
+for harness in pi claude codex gemini opencode; do
+ cp /bin/sleep "$DIR/bin/$harness" && codesign --force --sign - "$DIR/bin/$harness" 2>/dev/null
+done
+# A placeholder session holds the server up while the others are made.
+t -f /dev/null new-session -d -s _ -x 200 -y 50 "sleep 100000"
+
+pane=
+agent() { # session window harness state minutes name [option=value...]
+ local session=$1 window=$2 harness=$3 state=$4 minutes=$5 name=$6
+ shift 6
+ local run="$DIR/bin/$harness 100000"
+ if ! t has-session -t "=$session" 2>/dev/null; then
+ t new-session -d -s "$session" -n "$window" -c / "$run"
+ elif ! t list-windows -t "=$session" -F '#{window_name}' | grep -qx "$window"; then
+ t new-window -d -t "=$session:" -n "$window" -c / "$run"
+ else
+ t split-window -d -t "=$session:$window" -c / "$run"
+ fi
+ pane=$(t list-panes -t "=$session:$window" -F '#{pane_id}' | tail -1)
+ t set -p -t "$pane" @agent "$harness"
+ t set -p -t "$pane" @agent_state "$state"
+ t set -p -t "$pane" @dingger_since "$(ago "$minutes")"
+ t set -p -t "$pane" @agent_name "$name"
+ for kv in "$@"; do t set -p -t "$pane" "@${kv%%=*}" "${kv#*=}"; done
+}
+
+agent api db claude blocked 2 "Approve migration for the orders table"
+agent infra certs pi blocked 6 "Rotate staging TLS certificates" dingger_importance=high dingger_cost=0.42 dingger_context=18
+agent api tests pi done 4 "Fix flaky retry test in the HTTP client" dingger_cost=1.84 dingger_context=38
+agent web settings codex done 12 "Add a dark mode toggle to settings"
+agent web deps pi done 9 "Upgrade to React 19" dingger_cost=0.97 dingger_context=54 "dingger_error=429: rate limit exceeded"
+agent api perf pi working 3 "Profile the slow search endpoint" dingger_cost=3.10 dingger_context=76
+agent docs adr gemini working 1 "Write an ADR for the event bus"
+agent cli release pi working 7 "Cut the 2.4 release notes" dingger_cost=0.66 dingger_context=22
+agent web review claude idle 25 "Review PR #412: lazy-load the editor"
+agent cli config pi idle 40 "Refactor the config loader" "dingger_draft=also handle XDG_CONFIG_HOME" dingger_cost=2.15 dingger_context=61
+agent infra audit pi idle 90 "Nightly dependency audit" dingger_importance=low dingger_cost=0.31 dingger_context=9
+agent docs site opencode idle 55 "Migrate the docs site to Astro"
+agent api auth pi idle 130 "Add passkey sign-in" dingger_cost=4.02 dingger_context=93
+t kill-session -t =_
+
+t set -g @dingger_blocked 2
+t set -g @dingger_done 3
+t set -g @dingger_working 3
+t set -g @dingger_cost_today 23.40
+t set -g @dingger_cost_week 187.15
+t set -g @dingger_cost_month 612.80
diff --git a/docs/images/hero.png b/docs/images/hero.png
new file mode 100644
index 0000000..b3d734c
--- /dev/null
+++ b/docs/images/hero.png
Binary files differ
diff --git a/docs/images/panel.png b/docs/images/panel.png
new file mode 100644
index 0000000..99870de
--- /dev/null
+++ b/docs/images/panel.png
Binary files differ
diff --git a/docs/images/search.png b/docs/images/search.png
new file mode 100644
index 0000000..be8b2dd
--- /dev/null
+++ b/docs/images/search.png
Binary files differ
diff --git a/docs/images/statusline.png b/docs/images/statusline.png
new file mode 100644
index 0000000..1395b18
--- /dev/null
+++ b/docs/images/statusline.png
Binary files differ
diff --git a/docs/reference.md b/docs/reference.md
new file mode 100644
index 0000000..e159880
--- /dev/null
+++ b/docs/reference.md
@@ -0,0 +1,323 @@
+# dingger reference
+
+The details behind the [README](../README.md): how each part behaves, where
+state comes from, and how dingger is tested.
+
+## Statuses (herdr's, verbatim)
+
+| status | meaning | symbol | color | priority |
+|---------|--------------------------------------------------|--------|--------|----------|
+| blocked | needs your input | `×` | red | 4 |
+| done | finished a turn while you were looking elsewhere | `✓` | teal | 3 |
+| working | turn in flight | `◐` | yellow | 2 |
+| idle | at its prompt, nothing new since you looked | `○` | green | 1 |
+
+The state machine is herdr's `apply_pane_state_change` and
+`mark_active_tab_seen`, implemented as one pure function (`watcher::advance`)
+and unit-tested against herdr's own test cases:
+
+- a turn that finishes while you look at another window becomes **done**;
+- a turn that finishes in the window you are looking at stays **idle**;
+- looking at a window (a tmux window is herdr's tab) turns its done agents
+ **idle**, as does jumping to one from the menu bar or status line;
+- an agent seen for the first time is **idle**, not done.
+
+dingger watches tmux focus through tmux hooks, so attendance updates the
+moment you switch windows; agent reports apply in order, so a turn shorter
+than a poll is still a completion.
+
+## Features in detail
+
+- **Menu bar** (`menubar/main.swift`): `×1 ✓3 ◐2` in herdr's colors; the
+ menu lists every agent in priority order, oldest waiting first. Clicking a
+ row jumps to that pane.
+- **Floating panel** (`menubar/panel.swift`): an always-on-top HUD that never
+ takes focus, on every Space and over full-screen apps. It lists the agents
+ waiting for you (blocked, done) and summarizes the rest
+ (`◐ 2 working · ○ 15 idle`); click a row to jump there, drag to move it,
+ right-click an idle agent in the full list to mark it unread.
+ **⌃⌥⌘E** (or "Show Panel" in the menu) turns it on or off; off stays off
+ until you turn it back on. While on, "Panel Shows" picks *Only When Agents
+ Are Waiting* (default: it hides itself while nothing waits; turning it on
+ then shows it for 3 seconds so you see it is armed; compact) or *Always*
+ (with a "Show all / Show less" toggle for the full list). It grows down
+ to the bottom of the screen at most; a longer list scrolls, with the
+ summary line kept under it. The panel is always dark, in herdr's
+ palette, so it reads the same over any desktop.
+- **Jump shortcut**: **⌃⌥⌘U** jumps to the top waiting agent from anywhere,
+ like clicking the tmux status line.
+- **Agent search**: **⌃⌥⇧⌘U** opens a Spotlight-style search over every
+ agent. Type to fuzzy-filter (each word must match, letters in order, the
+ name, session, window, harness, status, or directory; or, as written,
+ a forgotten draft); ↑↓ or ⌃N ⌃P / ⌃J ⌃K
+ select, ↩ or a click jumps, esc closes, ⌃U clears, ⌘U (or a right-click)
+ marks the selected idle agent unread. With nothing typed,
+ agents are in the menu's order, so ↩ jumps to the top waiting agent. It
+ takes the keyboard without activating dingger, so focus returns where it
+ was.
+- **Restore**: dingger saves your tmux layout as it changes (sessions,
+ windows, panes, layouts, directories) under
+ `~/.local/state/dingger/snapshots/`. After a crash, shutdown, or
+ `kill-server`, `dingger restore` brings back what is missing (`--dry-run`
+ to preview, `--list` for saved snapshots, `--session` for some). Sessions
+ that exist are left alone, except a bare one of the same name (what a
+ terminal creates on startup), which is replaced with its clients moved
+ over. Agents are resumed in their own sessions: pi with `pi --session
+ <file>` (reported by the pi extension), Claude Code with `claude --resume
+ <id>` (reported by its SessionStart hook, `dingger install claude`), in
+ the directory they ran in. A session that never got a message has nothing
+ to resume, so its command line is typed instead. Agents that were
+ mid-turn are listed. Other panes get the command line that was running typed at the
+ prompt, not run: exactly as typed with the zsh hook (`dingger install
+ zsh`, then source the file it names from your `.zshrc`), otherwise the
+ program's arguments. An agent or program that exits within a minute
+ before tmux stops (a shutdown) is still restored.
+ When tmux stops, the panel (first row: "↺ Restore 9 sessions · 22
+ agents", or "Not now"), the menu (all, or one session at a time), and the
+ status line (`↺9`, clickable) offer the restore; it runs through your
+ login shell, like a terminal starting tmux, and a notification
+ summarizes (full report: `~/.local/state/dingger/restore.log`).
+- All shortcuts live in `~/.config/dingger/config.toml` (menu bar,
+ Settings…), reloaded on save.
+- **Jumping to an agent** (any click, a shortcut, search) shows it in a
+ terminal window and raises that window. With several terminal windows,
+ one already on the agent's session wins (it shows the agent without
+ switching), then the focused one, then the one used last. In Ghostty
+ (1.3+), the exact window, tab, and split is raised: dingger learns which
+ terminal holds each tmux client when it gains focus (tmux's focus hook
+ asks Ghostty which terminal is focused; nothing visible changes).
+ Other terminals: the app is activated.
+- **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after
+ the clock. Clicking it jumps to the top waiting agent (the menu's top row),
+ skipping the window you already look at.
+- **Notifications**: "needs attention" (blocked) and "finished" (done), with
+ herdr's sounds; silent for completions you are already watching. A pi
+ run that ends in an error (after pi's own retries) is still done, but
+ says so: "failed" with the error as one readable line (`401: invalid
+ x-api-key`), with the "needs attention" sound, and `failed 3m · <why>`
+ in red where the wait shows (plain text in the menu; `dingger status`
+ too), until you have seen it or it runs again.
+- **Importance**: mark an agent high, normal (the default), or low. Within a
+ status, more important agents are listed first, so a jump goes to them
+ first; status still comes first (a blocked low agent before a done high
+ one). A high agent shows a peach `!` before its name (in a column of its
+ own, so names line up); a low agent's name is dimmed, its second line
+ says "low", and it makes no sound (it
+ still notifies, and waits in line like any other). In pi, type
+ `/importance high|normal|low` (no argument: the current level); pi saves
+ it with the session, so it comes back after `/reload`, a restart, or a
+ restore. For other agents,
+ run `dingger importance high|normal|low` in the agent's pane (in Claude
+ Code: `! dingger importance high`, which Claude may answer with a model
+ turn), or pass `--pane`; it lasts until the agent exits.
+- **Mark as unread**: like in mail, an idle agent (one you have seen) can be
+ made done again, to come back to later: right-click it in the panel's
+ full list, ⌘U in agent search, `/unread` in pi, or `dingger unread
+ [--pane]`. It goes last among done agents of its importance, with no
+ sound or notification, and stays done until you come back to it: you
+ leave its tmux window and return, or jump to it. Only idle agents can be
+ marked: a working, blocked, or done one is left alone. Claude Code has no
+ `/unread`, and `! dingger unread` does not work in it: Claude (2.1.311)
+ answers a `!` command with a model turn, so the agent is working and the
+ mark is refused. Use the panel or search for Claude agents.
+- **What agents cost** (pi): the menu's top line totals what pi agents
+ spent today, this week, and this month (`$8.86 today · $457 this week ·
+ $8.86 this month`); each agent's line adds what its session spent today
+ (at the right edge in the panel and search, after the place in the
+ menu), and `dingger status` shows both. Periods start at
+ 00:00 UTC (a week on Monday, a month on its 1st). The daemon reads pi's
+ session files (`~/.pi/agent/sessions`, and those of running agents
+ elsewhere) in a thread of its own every 15 seconds, only what was
+ appended since. Each reply counts once, for the session it first appeared
+ in: a fork (`/fork`, `pi --fork`) copies its parent's replies into its
+ own file, and summing every file would count them again (in one month
+ here, $1,024 of $2,991). The totals include agents since closed or
+ started over. Amounts are what pi prices each call at (its model
+ configuration), counted as pi's footer counts them: replies, compaction
+ and branch summaries, and other recorded calls. Claude Code records no
+ prices, so it is not counted.
+- **Context use** (pi): how full each agent's context window is, the
+ number pi's own footer shows. The panel and search draw it as a small
+ meter at the right edge, after the cost (gray; yellow above 70% and red
+ above 90%, as in pi; the number on hover); the menu adds it as text
+ (`$0.70 · 10%`), and `dingger status` has a CTX column. The extension
+ reports it after each message, compaction, model change, and turn; right
+ after compacting pi does not know it until the next reply, and neither
+ does dingger (no meter). Agents on an older extension show none until
+ they reload.
+- **Session names** (pi): every pi session gets a short name of what it is
+ about, from the model, after its first run settles; it is refreshed
+ every 8 of your messages, and an old unnamed session is named when you
+ resume it. A name you give (`/name`, `pi --name`, `/autoname <name>`) is
+ never replaced; `/autoname` alone renames now. It runs in every pi
+ session (print runs and outside tmux too) and costs one short model call
+ per naming; `PI_AUTONAME_MODEL=provider/model-id` picks a cheaper model.
+ It replaces the `pi-auto-session-name` package (remove it: `pi remove
+ git:github.com/Optomatica/pi-auto-session-name`), and keeps the names
+ that package gave automatic.
+- **Forgotten drafts** (pi): an agent with text left in its input box shows
+ `✎` before its name wherever you are not looking at it (where you look,
+ you may be typing it). Idle agents with one come first among idle agents;
+ the compact panel counts them (`✎ 2 drafts`, a click jumps to the
+ first); search finds an agent by its draft's words (as written, not
+ fuzzily); `dingger status` marks them too. No sound, no notification.
+ The extension reads the input box half a second after you stop typing,
+ and keeps each session's draft in a file
+ (`~/.local/state/dingger/drafts/`) that puts it back in the input box
+ when the session comes back: after a crash, a restore, or `pi --session`.
+ The draft is kept on the pane as one line (`@dingger_draft`), never in
+ dingger's log.
+
+## Where state comes from
+
+1. **Agent hooks** (authoritative): `dingger install pi` installs a pi
+ extension that reports working/blocked/idle over
+ `~/.local/state/dingger/agent.sock`. Running pi agents load it when
+ reloaded (see "Keeping pi agents on the current extension" below).
+ It also reports the session's name (`/name`, or pi's own), which is
+ the agent's name in dingger whatever the terminal title says: any
+ program in the pane can set a title. Agents without it (Claude Code, a
+ pi on an older extension) are named from their terminal title.
+ Blocked covers a dialog that opens during a run (an extension asking
+ before it goes on), until you answer it; one you open while the agent
+ is idle changes nothing. A run you cancel (Escape) ends idle, not done,
+ with no "finished" notification, even when it settles after you look
+ away.
+ `dingger install claude` adds a SessionStart hook to
+ `~/.claude/settings.json` (keeping everything else; the original is
+ backed up to `settings.json.bak-dingger`) that reports only Claude's
+ session, for restore; its state comes from screen matching.
+2. **Screen matching** (fallback): `capture-pane` text through herdr's
+ manifests, for agents without hooks.
+
+All per-pane state lives in tmux pane options (`@agent_state`,
+`@dingger_since`, `@dingger_hook`, `@dingger_importance`,
+`@dingger_extension`, `@dingger_control`, `@dingger_draft`,
+`@dingger_cost`, `@dingger_context`, `@dingger_error`), so restarting
+dingger loses nothing.
+
+### Keeping pi agents on the current extension
+
+When the daemon starts, it updates the installed pi extension if this
+dingger's differs (only its own file, which says so on its first line).
+Running agents keep the one they loaded, and report its version. While
+some run an old one, the menu bar menu offers **Reload N Agents on an Old
+Extension**; `dingger reload-agents` does the same. Each agent reloads
+(pi's `/reload`) when:
+
+- it is idle (done included: it stays done),
+- it is not on screen, where you may be typing into it,
+- and its input box is empty: the extension checks this itself, at the
+ moment it reloads, so nothing you type is lost or sent.
+
+The others reload once they are; the request stays until all are, or the
+daemon restarts. dingger sends no keystrokes: it asks the extension over a
+socket of its own (`~/.local/state/dingger/pi-<pid>-<id>.sock`), and the
+extension runs the reload as a command (`/dingger-reload`, which you can
+also type). A reload also resets pi's other extensions: a pending alarm
+is lost, MCP servers reconnect.
+
+An agent on an extension from before this (it reports no version) takes
+no requests: the menu says how many need `/reload` by hand, once.
+
+The pi extension's reports carry the agent's importance. Other pi
+extensions can follow it on pi's event bus: `dingger:importance` with
+`{ level }`, at session start and on every change; it is saved in the
+session as a custom entry of type `dingger-importance`.
+
+## Install in detail
+
+```bash
+cargo build --release
+cp target/release/dingger ~/.local/bin/dingger # do not strip: invalidates
+codesign --force --sign - ~/.local/bin/dingger # the signature, macOS kills it
+swiftc -O -o target/dingger-menu menubar/*.swift # menu bar + panel, bundled
+ # as ~/Applications/dingger.app
+```
+
+- launchd agents (start at login, restart on exit):
+ `packaging/launchd/dev.dingger.{dingger,menubar}.plist`; adjust the user
+ path, copy to `~/Library/LaunchAgents/`, `launchctl bootstrap gui/$(id -u) <plist>`.
+- tmux: you add the snippet yourself; no `dingger install` command does it.
+ Put this line at the end of your tmux config (`~/.tmux.conf`, or a file
+ it sources):
+
+ ```tmux
+ source-file /path/to/dingger/packaging/tmux/dingger.tmux
+ ```
+
+ Then reload: `tmux source-file ~/.tmux.conf`. The snippet:
+ - **replaces** `status-right` with the restore offer, `%d-%b %H:%M`, and
+ the agent counts. Your own `status-right` is lost, so put the line after
+ it, or edit the snippet's `status-right` to keep what you had;
+ - turns on `focus-events`, which raising the right Ghostty window needs;
+ - adds hooks that report focus and navigation to dingger. They use index
+ `[77]`, so they keep your own hooks on the same events, and reloading
+ does not duplicate them;
+ - binds clicks on the right of the status line, which needs
+ `set -g mouse on`.
+
+ The line sources the snippet from the repo, so a changed snippet applies
+ at the next reload.
+
+dingger does not need tmux running first; it attaches when a server appears.
+
+## Testing
+
+```bash
+cargo test # engine tests + herdr state-machine tests
+tests/e2e.sh # end to end against an isolated tmux server
+tests/pi-live.sh <pi> # a real pi binary against dingger (new pi releases)
+tests/shutdown.sh [dingger] [rounds] # restore after the ways an OS stops tmux
+```
+
+`tests/e2e.sh` starts its own tmux server, its own daemon (polling only every
+30 s, so everything asserted must arrive through events), fake agents driven
+over the hook protocol, and a real tmux client whose focus it controls. It
+asserts every status transition, attendance by navigation, both click paths
+(including the real menu bar binary), the floating panel's visibility and
+layout rules and content (compact, full, toggle), agent search (matching,
+ranking, selection), the status-line segment, latency with a
+deliberately slow notifier, and state surviving a daemon restart. It never
+touches your tmux server.
+
+`tests/pi-live.sh <pi binary>` checks a real pi release on a tmux server and
+daemon of its own, with a fake local model (`tests/fake-model.py`) and its
+own pi agent directory: no network, no credentials, your pi untouched. It
+covers identification, the extension's state and session reports, a turn,
+`/reload`, an extension's blocked event, dialogs (blocked during a run,
+nothing while idle), a cancelled run (idle, not done), a failed run (done,
+with why; cleared when seen or run again), names (the session's, not the
+terminal title's; named after the first run, renamed every 8 messages,
+yours kept, print runs and resumed old sessions named), screen detection
+without the
+extension, restore (after tmux and its real pis are stopped as a restart
+stops them: SIGTERM to all at once, then SIGKILL), reloading an agent on an old extension (and refusing
+while busy or with a draft), drafts (reported, cleared, kept across a
+crash and put back by restore), cost (priced replies, counted once across
+a real `pi --fork`), context use (pi's footer's number, through a
+compaction), and exit. To try a release without installing it:
+`npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>`
+(through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`.
+
+`tests/shutdown.sh` stops tmux the ways a restart or logout can, each on
+servers, a daemon and a state directory of its own, with fake agents: SIGTERM
+to tmux; to the agents first; the daemon first; the terminal quitting first;
+and, for 20 rounds each by default, SIGTERM and SIGKILL to everything at once
+(tmux, its panes, the terminal's client, the daemon) in a random order at a
+random moment. After each it logs in again (a fresh daemon if its own was
+stopped; a terminal running `tmux new-session -As 0`) and checks the old
+layout is offered and restoring brings back every session, window, pane,
+directory, agent and command.
+
+## Layout
+
+- `crates/detect-core`: the detection engine and 22 manifests, from herdr.
+- `crates/dinggerd`: the `dingger` binary (watcher, hooks, focus, CLI).
+- `menubar/`: the menu bar app (`model.swift` rows and colors, `panel.swift`
+ the floating panel, `hotkey.swift` the global shortcut and settings).
+- `packaging/`: launchd agents and the tmux snippet.
+- `tests/`: the end-to-end suite (`e2e.sh`), a real pi (`pi-live.sh`),
+ shutdowns (`shutdown.sh`).
+- `docs/`: this reference, the README's images, and the script that makes
+ them (`screenshots.sh`, from made-up agents in `demo-agents.sh`).
diff --git a/docs/screenshots.sh b/docs/screenshots.sh
new file mode 100755
index 0000000..ffe7291
--- /dev/null
+++ b/docs/screenshots.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env bash
+# The README's images, from made-up agents on a tmux server of their own.
+#
+# docs/screenshots.sh
+#
+# Needs a release build (cargo build --release), the menu bar app
+# (target/dingger-menu), and Google Chrome (headless, to compose the
+# images). The panel and search are drawn by the real app
+# (--snapshot-panel, --snapshot-search); the terminal is a real tmux client's
+# screen, colored as two terminal themes would color it. The menu bar strip
+# and window frames are drawn around them. Writes docs/images/*.png.
+set -eu
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+OUT="$ROOT/docs/images"
+WORK=$(mktemp -d /tmp/dingger-shots.XXXXXX)
+SOCK=dingger-demo-$$
+TERMSOCK=dingger-demo-term-$$
+CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+DINGGER="$ROOT/target/release/dingger"
+MENU="$ROOT/target/dingger-menu"
+trap 'tmux -L "$SOCK" kill-server 2>/dev/null; tmux -L "$TERMSOCK" kill-server 2>/dev/null; [ -n "${SHOTS_KEEP:-}" ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
+mkdir -p "$OUT"
+
+"$ROOT/docs/demo-agents.sh" "$SOCK" "$WORK/demo"
+t() { tmux -u -L "$SOCK" "$@"; }
+app() { env DINGGER_TMUX_SOCKET="$SOCK" DINGGER_STATE_DIR="$WORK/state" "$MENU" "$@"; }
+
+# --- the app's own renders ----------------------------------------------------
+mkdir -p "$WORK/state"
+app --snapshot-panel "$WORK/panel.png"
+app --snapshot-panel "$WORK/panel-full.png" --full
+matched=$(app --print-search edit | tail -1 | cut -c1-7)
+[ "$matched" = "4 of 13" ] || { echo "search: 'edit' should match 4 of 13 agents, got: $matched"; exit 1; }
+app --snapshot-search "$WORK/search.png" "edit"
+# After a restart: the offer to restore.
+saved=$(($(date +%s) * 1000 - 3 * 60000))
+cat >"$WORK/state/offer.json" <<EOF
+{"saved_at": $saved, "agents": 22, "interrupted": 2, "sessions": [
+ {"name": "api", "windows": 6, "panes": 9, "agents": 7}, {"name": "web", "windows": 4, "panes": 6, "agents": 5},
+ {"name": "infra", "windows": 3, "panes": 4, "agents": 3}, {"name": "docs", "windows": 3, "panes": 3, "agents": 2},
+ {"name": "cli", "windows": 4, "panes": 5, "agents": 3}, {"name": "scratch", "windows": 2, "panes": 2, "agents": 0},
+ {"name": "notes", "windows": 1, "panes": 1, "agents": 0}, {"name": "ops", "windows": 2, "panes": 3, "agents": 2},
+ {"name": "0", "windows": 1, "panes": 1, "agents": 0}]}
+EOF
+app --snapshot-panel "$WORK/panel-restore.png"
+rm "$WORK/state/offer.json"
+
+# --- a terminal: tmux with `dingger status` and the status line ---------------
+prompt='\033[1;34m~/src/api\033[0m \033[35m❯\033[0m'
+cat >"$WORK/term.sh" <<EOF
+#!/bin/sh
+clear
+printf '$prompt dingger status\n'
+DINGGER_TMUX_SOCKET=$SOCK $DINGGER status
+printf '\n$prompt '
+exec sleep 100000
+EOF
+chmod +x "$WORK/term.sh"
+t new-session -d -s main -n api -c / "$WORK/term.sh"
+t set -g status-style "fg=colour244,bg=default"
+t set -g status-left "#[fg=blue,bold] #S #[default]"
+t set -g status-left-length 20
+t set -g window-status-format " #I:#W "
+t set -g window-status-current-format "#[fg=default,bold] #I:#W* #[default]"
+t set -g status-right-length 80
+grep '^set -g status-right' "$ROOT/packaging/tmux/dingger.tmux" >"$WORK/status.tmux"
+t source-file "$WORK/status.tmux"
+tmux -u -L "$TERMSOCK" -f /dev/null new-session -d -x 100 -y 20 \
+ "exec env -u TMUX TERM=xterm-256color tmux -u -L $SOCK attach -t main"
+sleep 1.5
+tmux -u -L "$TERMSOCK" capture-pane -p -e -t 0 >"$WORK/term.ansi"
+
+# ANSI to HTML spans: classes for the 16 colors (each theme styles them),
+# inline colors for the 256-color grays.
+python3 - "$WORK/term.ansi" "$WORK/term.html" "$WORK/term-tail.html" <<'PY'
+import html, re, sys
+lines = open(sys.argv[1]).read().rstrip("\n").split("\n")
+fg, bold, dim = None, False, False
+def gray(n):
+ if n < 16: return None
+ if n >= 232: v = 8 + (n - 232) * 10; return f"rgb({v},{v},{v})"
+ n -= 16; return "rgb(%d,%d,%d)" % tuple(0 if c == 0 else 55 + c * 40 for c in (n // 36, n // 6 % 6, n % 6))
+def span(s):
+ if not s: return ""
+ cls, style = [], ""
+ if isinstance(fg, int): cls.append(f"c{fg}")
+ elif fg: style = f"color:{fg}"
+ if bold: cls.append("b")
+ if dim: cls.append("d")
+ return f'<span class="{" ".join(cls)}" style="{style}">{html.escape(s)}</span>'
+def convert(text):
+ global fg, bold, dim
+ fg, bold, dim = None, False, False
+ out = []
+ for part in re.split(r"(\x1b\[[0-9;]*m)", text):
+ m = re.fullmatch(r"\x1b\[([0-9;]*)m", part)
+ if not m:
+ out.append(span(part)); continue
+ codes = [int(c) if c else 0 for c in m.group(1).split(";")]
+ i = 0
+ while i < len(codes):
+ c = codes[i]
+ if c == 0: fg, bold, dim = None, False, False
+ elif c == 1: bold = True
+ elif c == 2: dim = True
+ elif c == 22: bold = dim = False
+ elif 30 <= c <= 37: fg = c - 30
+ elif 90 <= c <= 97: fg = c - 90 + 8
+ elif c == 39: fg = None
+ elif c == 38 and codes[i + 1] == 5:
+ n = codes[i + 2]; fg = n if n < 16 else gray(n); i += 2
+ elif c == 38 and codes[i + 1] == 2:
+ fg = "rgb(%d,%d,%d)" % tuple(codes[i + 2:i + 5]); i += 4
+ elif c in (48,):
+ i += 2 if codes[i + 1] == 5 else 4
+ i += 1
+ return "".join(out)
+open(sys.argv[2], "w").write(convert("\n".join(lines)))
+# The prompt and the status line.
+open(sys.argv[3], "w").write(convert("\n".join(lines[-2:])))
+PY
+
+# --- compose ---------------------------------------------------------------------
+# A snapshot is the view drawn twice, over white and over black, side by side
+# (points: 2w+20 wide); the second copy is the one composed here.
+size() { sips -g pixelWidth -g pixelHeight "$1" | awk '/pixelWidth/{w=$2} /pixelHeight/{h=$2} END{print w/2, h/2}'; }
+crop() { # name png -> CSS for a div showing the second copy
+ read -r w h < <(size "$2")
+ local half=$(echo "($w - 20) / 2" | bc)
+ echo ".$1{width:${half}px;height:${h}px;background:url('$2') no-repeat;background-size:${w}px ${h}px;background-position:-$((half + 20))px 0}"
+}
+TERM_HTML=$(cat "$WORK/term.html")
+TERM_TAIL=$(cat "$WORK/term-tail.html")
+cat >"$WORK/style.css" <<EOF
+$(crop panel "$WORK/panel.png")
+$(crop panelfull "$WORK/panel-full.png")
+$(crop panelrestore "$WORK/panel-restore.png")
+$(crop search "$WORK/search.png")
+* { box-sizing: border-box; margin: 0 }
+body { font-family: -apple-system, "SF Pro Text", sans-serif; }
+.wall { position: relative; overflow: hidden;
+ background: radial-gradient(1200px 700px at 15% 10%, #4f5d95 0%, transparent 60%),
+ radial-gradient(1000px 800px at 90% 100%, #8a4f7d 0%, transparent 55%),
+ linear-gradient(160deg, #1d2140, #2d2346 55%, #1b1f33); }
+.shadow { border-radius: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.45), 0 0 0 .5px rgba(255,255,255,.12); }
+.menubar { position: absolute; top: 0; left: 0; right: 0; height: 26px; background: rgba(30,30,46,.55);
+ backdrop-filter: blur(20px); color: #e6e6ef; font-size: 13px; display: flex; align-items: center;
+ padding: 0 14px; gap: 18px; }
+.menubar .right { margin-left: auto; display: flex; gap: 16px; align-items: center; }
+.menubar .item { font-weight: 500; font-variant-numeric: tabular-nums; padding: 1px 7px; border-radius: 5px; background: rgba(255,255,255,.14) }
+.r { color: #f38ba8 } .t { color: #94e2d5 } .y { color: #f9e2af }
+.term { border-radius: 11px; overflow: hidden; }
+.term .bar { height: 30px; display: flex; align-items: center; gap: 8px; padding: 0 12px; font-size: 12.5px; }
+.term .dot { width: 12px; height: 12px; border-radius: 50%; }
+.term .title { flex: 1; text-align: center; margin-right: 52px; opacity: .7 }
+.term pre { font: 13px/1.38 "SF Mono", Menlo, monospace; padding: 10px 14px 6px; white-space: pre; }
+.b { font-weight: 700 } .d { opacity: .6 }
+/* Catppuccin Mocha */
+.dark { background: #1e1e2e; color: #cdd6f4 } .dark .bar { background: #181825; color: #cdd6f4 }
+.dark .c0{color:#45475a}.dark .c1{color:#f38ba8}.dark .c2{color:#a6e3a1}.dark .c3{color:#f9e2af}.dark .c4{color:#89b4fa}.dark .c5{color:#f5c2e7}.dark .c6{color:#94e2d5}.dark .c7{color:#bac2de}
+/* PaperColor Light */
+.light { background: #eeeeee; color: #4d4d4c } .light .bar { background: #e2e2e2; color: #4d4d4c }
+.light .c0{color:#ededed}.light .c1{color:#d7005f}.light .c2{color:#718c00}.light .c3{color:#d75f00}.light .c4{color:#4271ae}.light .c5{color:#8959a8}.light .c6{color:#3e999f}.light .c7{color:#4d4d4c}
+.dots { display: contents } .dot.a { background: #ff5f57 } .dot.b2 { background: #febc2e } .dot.c { background: #28c840 }
+.label { color: rgba(255,255,255,.7); font-size: 12px; margin: 0 0 7px 4px }
+.caption { color: rgba(255,255,255,.75); font-size: 13px; text-align: center; margin-top: 12px }
+EOF
+termwin() { # theme title
+ echo "<div class=\"term shadow $1\"><div class=\"bar\"><span class=\"dot a\"></span><span class=\"dot b2\"></span><span class=\"dot c\"></span><span class=\"title\">$2</span></div><pre>$TERM_HTML</pre></div>"
+}
+page() { # name width height body
+ printf '<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="style.css"><body style="width:%spx;height:%spx">%s</body>' "$2" "$3" "$4" >"$WORK/$1.html"
+ rm -f "$WORK/$1-out.png"
+ "$CHROME" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=2 \
+ --window-size="$2,$3" --screenshot="$WORK/$1-out.png" "file://$WORK/$1.html" >/dev/null 2>&1
+ for _ in $(seq 100); do [ -s "$WORK/$1-out.png" ] && break; sleep 0.1; done
+ cp "$WORK/$1-out.png" "$OUT/$1.png"
+ echo "wrote docs/images/$1.png"
+}
+
+clock=$(date "+%a %-d %b %H:%M")
+menubar="<div class=\"menubar\"><b>Ghostty</b><span>Shell</span><span>Edit</span><span>View</span><span>Window</span>
+<div class=\"right\"><span class=\"item\"><span class=\"r\">×2</span> <span class=\"t\">✓3</span> <span class=\"y\">◐3</span></span><span>$clock</span></div></div>"
+page hero 1320 500 "<div class=\"wall\" style=\"width:1320px;height:500px\">$menubar
+<div style=\"position:absolute;left:30px;top:58px;width:822px\">$(termwin dark 'tmux — api')</div>
+<div class=\"panel shadow\" style=\"position:absolute;right:30px;top:42px;border-radius:12px\"></div></div>"
+
+page panel 1000 690 "<div class=\"wall\" style=\"width:1000px;height:690px;display:flex;gap:40px;padding:44px 40px;align-items:flex-start\">
+<div><div class=\"panel shadow\"></div><div class=\"caption\">Only when agents are waiting</div>
+<div class=\"panelrestore shadow\" style=\"margin-top:34px\"></div><div class=\"caption\">After a restart: restore everything</div></div>
+<div><div class=\"panelfull shadow\"></div><div class=\"caption\">Always, with the full list</div></div></div>"
+
+page search 700 360 "<div class=\"wall\" style=\"width:700px;height:360px;padding:50px 60px\"><div class=\"search shadow\" style=\"border-radius:14px\"></div></div>"
+
+page statusline 900 250 "<div class=\"wall\" style=\"width:900px;height:250px;padding:22px 30px\">
+<div class=\"label\">a dark theme (Catppuccin Mocha)</div><div class=\"term shadow dark\"><pre>$TERM_TAIL</pre></div>
+<div class=\"label\" style=\"margin-top:18px\">a light theme (PaperColor Light)</div><div class=\"term shadow light\"><pre>$TERM_TAIL</pre></div></div>"