jevhooks.git / tools / screenshots.sh
1#!/usr/bin/env bash
2# Redraw the pictures in the READMEs from a real Claude Code session.
3#
4#     tools/with-secrets.sh tools/screenshots.sh      (mise run screenshots)
5#
6# Nothing here is drawn by hand. A session runs in a detached tmux with the plugin loaded, against a
7# daemon of its own (its own state and config directories, so the memory it "measures" is a number in a
8# file this script writes). Each scene sends a prompt, waits for a line to appear on the screen, and
9# turns the screen into an SVG with freeze. A scene whose line never appears fails the run and prints
10# the screen: a picture of the wrong moment is worse than none.
11#
12# The session reads no user settings (`--setting-sources project,local`), so the pictures show Claude
13# Code as it comes, not the status line of whoever ran this.
14#
15# Needs: TYPESAFE_API_KEY in the environment, a signed-in `claude` (CLAUDE names another binary), the
16# daemon built (`mise run daemon`), and tmux, freeze and git, which `mise install` brings.
17set -euo pipefail
18root="$(cd "$(dirname "$0")/.." && pwd)"
19claude="${CLAUDE:-claude}"
20die() { echo "screenshots: $*" >&2; exit 1; }
21for tool in tmux freeze git; do command -v "$tool" >/dev/null || die "$tool is not installed (mise install)"; done
22[ -n "${TYPESAFE_API_KEY:-}" ] || die "TYPESAFE_API_KEY is not set (see fnox.toml)"
23[ -x "$root/plugin/bin/jevhooks" ] || die "the daemon is not built (mise run daemon)"
24
25# Short on purpose: the daemon's socket lives under it, and a socket's path has 103 bytes.
26work="$(mktemp -d "${TMPDIR:-/tmp}/jevshots.XXXXXX")"
27[ -d "$work" ] || die "no scratch directory"
28export XDG_STATE_HOME="$work/state" XDG_CONFIG_HOME="$work/config"
29# A tmux server of this run's own: another run's cleanup must not be able to end this one's session.
30t() { tmux -L "jevshots-$$" "$@"; }
31cleanup() {
32  t kill-server 2>/dev/null || true
33  "$root/plugin/bin/jevhooks" stop >/dev/null 2>&1 || true
34  rm -rf "$work"
35}
36trap cleanup EXIT
37
38mkdir -p "$XDG_CONFIG_HOME/jevhooks" "$work/demo"
39printf 'available_memory_command = "cat %s/free"\nchoose_model = true\n' "$work" > "$XDG_CONFIG_HOME/jevhooks/config.toml"
40echo 64000 > "$work/free"
41
42# A small project with a little history, for the session to work in. `git -C` and not `cd`: a `cd`
43# that failed would leave every command after it running in this repository.
44demo="$work/demo"
45git -C "$demo" init -q -b main
46git -C "$demo" config user.name demo
47git -C "$demo" config user.email demo@example.com
48printf 'fn main() {\n    println!("hello");\n}\n' > "$demo/main.rs"
49git -C "$demo" add -A
50git -C "$demo" commit -q -m "hello"
51printf '# demo\n' > "$demo/README.md"
52git -C "$demo" add -A
53git -C "$demo" commit -q -m "a readme"
54
55screen() { t capture-pane -p -t shots; }
56
57# wait_for <seconds> <regex>: until the screen shows it. Fails with the screen when it never does.
58wait_for() {
59  local until=$((SECONDS + $1))
60  while ! screen | grep -Eq -- "$2"; do
61    [ "$SECONDS" -lt "$until" ] || { screen >&2; die "the screen never showed: $2"; }
62    sleep 0.2
63  done
64}
65
66# say <prompt>: types it and presses Enter (a separate keypress, or the TUI takes it as a newline).
67say() { t send-keys -t shots -l "$1"; sleep 0.5; t send-keys -t shots Enter; }
68
69# Rows Claude Code draws about the account of whoever runs this (how much of a plan's limit is used,
70# and when it resets, in their timezone). They are no part of the plugin and do not belong in a README.
71private='weekly limit|usage-credits|session limit'
72
73# shot <file> <rows>: the last <rows> rows of the screen that have something on them, as an SVG. The
74# font is a list, not an embedded file: a browser takes each character from the first font that has it,
75# which is what gets the TUI's symbols drawn everywhere.
76shot() {
77  t capture-pane -e -p -t shots \
78    | awk '{ rows[NR] = $0 } /[^[:space:]]/ { last = NR } END { for (i = 1; i <= last; i++) print rows[i] }' \
79    | grep -Ev "$private" | tail -n "$2" > "$work/shot.ansi"
80  freeze "$work/shot.ansi" --language ansi --output "$root/$1" --window=false --padding 20 --background "#171717" \
81    --font.family "JetBrains Mono, Cascadia Code, DejaVu Sans Mono, Menlo, Consolas, monospace, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji" \
82    </dev/null >/dev/null # freeze reads a piped stdin in place of its file, and waits on it for ever
83  echo "screenshots: wrote $1"
84}
85
86# idle: the turn is over (the band shows its end).
87idle() { wait_for 120 'turn end: '; sleep 1; }
88
89t new-session -d -s shots -x 120 -y 28 -c "$demo" "$claude --plugin-dir '$root/plugin' --setting-sources project,local --permission-mode manual"
90t set-option -g focus-events on # or the TUI spends a row of every picture asking for it
91wait_for 60 'trust this folder|for shortcuts'
92# The scratch project is new, so Claude Code asks whether to trust it, with "No, exit" selected. Enter
93# is pressed only once the screen shows the cursor on "Yes": a Down the dialog was not ready for would
94# otherwise make Enter mean "exit", and the session would be gone.
95if screen | grep -q 'trust this folder'; then
96  for _ in $(seq 1 20); do
97    screen | grep -q '❯ Yes, I trust' && break
98    t send-keys -t shots Down
99    sleep 0.5
100  done
101  screen | grep -q '❯ Yes, I trust' || { screen >&2; die "could not select Yes in the trust dialog"; }
102  t send-keys -t shots Enter
103fi
104# The session starts the daemon; wait until it answers.
105for _ in $(seq 1 300); do "$root/plugin/bin/jevhooks" status >/dev/null 2>&1 && break; sleep 0.2; done
106"$root/plugin/bin/jevhooks" status >/dev/null 2>&1 || {
107  screen >&2
108  cat "$XDG_STATE_HOME/jevhooks/daemon.log" >&2 2>/dev/null || true
109  die "the session's daemon never answered"
110}
111sleep 2
112
113# 1. A prompt is given to a model. The band says which, until the turn's end replaces the line.
114say 'What branch am I on? Answer in three words.'
115wait_for 30 'prompt: Jev: '
116shot crates/jevhooks-daemon/src/model.svg 9
117idle
118
119# 2. An ordinary command runs without a prompt, and the band says what Jev made of it.
120say 'Run exactly this shell command, then reply with the one word done: mkdir -p build && echo hello > build/out.txt'
121wait_for 60 'command: Jev: .*allowed'
122shot plugin/hooks/band.svg 12
123idle
124
125# 3. A command with no room waits. The memory "available" drops to 1 GB, then comes back.
126echo 1000 > "$work/free"
127sleep 6 # the daemon reads the figure at most every 5 s
128say 'Run exactly this shell command, then reply with the one word done: cargo build --release'
129wait_for 60 'waiting for memory'
130sleep 3
131shot crates/jevhooks-daemon/src/waiting.svg 12
132echo 64000 > "$work/free"
133idle
134
135# 4. A command that is hard to undo is put to the user, with Jev's reason in the dialog.
136dialog='Do you want to proceed|requires confirmation'
137say 'I want my last commit and every untracked file gone. Run exactly this shell command: git reset --hard HEAD~1 && git clean -fdx'
138wait_for 30 'prompt: Jev: '
139wait_for 120 "$dialog|turn end: "
140# A careful model asks before it tries; say yes, and the command reaches the hook.
141if ! screen | grep -Eq -- "$dialog"; then
142  say 'Yes, run it now.'
143  wait_for 120 "$dialog"
144fi
145sleep 1
146shot plugin/hooks/ask.svg 22
147t send-keys -t shots Escape