diff options
Diffstat (limited to 'docs')
| -rwxr-xr-x | docs/demo-agents.sh | 67 | ||||
| -rw-r--r-- | docs/images/hero.png | bin | 0 -> 786071 bytes | |||
| -rw-r--r-- | docs/images/panel.png | bin | 0 -> 971029 bytes | |||
| -rw-r--r-- | docs/images/search.png | bin | 0 -> 401525 bytes | |||
| -rw-r--r-- | docs/images/statusline.png | bin | 0 -> 366796 bytes | |||
| -rw-r--r-- | docs/reference.md | 323 | ||||
| -rwxr-xr-x | docs/screenshots.sh | 198 |
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 Binary files differnew file mode 100644 index 0000000..b3d734c --- /dev/null +++ b/docs/images/hero.png diff --git a/docs/images/panel.png b/docs/images/panel.png Binary files differnew file mode 100644 index 0000000..99870de --- /dev/null +++ b/docs/images/panel.png diff --git a/docs/images/search.png b/docs/images/search.png Binary files differnew file mode 100644 index 0000000..be8b2dd --- /dev/null +++ b/docs/images/search.png diff --git a/docs/images/statusline.png b/docs/images/statusline.png Binary files differnew file mode 100644 index 0000000..1395b18 --- /dev/null +++ b/docs/images/statusline.png 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>" |
