jevhooks.git / tools / screenshots.sh
1#!/usr/bin/env bash

Redraw the pictures in the READMEs from a real Claude Code session.

tools/with-secrets.sh tools/screenshots.sh      (mise run screenshots)

Nothing here is drawn by hand. A session runs in a detached tmux with the plugin loaded, against a daemon of its own (its own state and config directories, so the memory it "measures" is a number in a file this script writes). Each scene sends a prompt, waits for a line to appear on the screen, and turns the screen into an SVG with freeze. A scene whose line never appears fails the run and prints the screen: a picture of the wrong moment is worse than none.

The session reads no user settings (--setting-sources project,local), so the pictures show Claude Code as it comes, not the status line of whoever ran this.

Needs: TYPESAFE_API_KEY in the environment, a signed-in claude (CLAUDE names another binary), the 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)"

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"

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
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"

A small project with a little history, for the session to work in. git -C and not cd: a cd 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"
55screen() { t capture-pane -p -t shots; }

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}

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; }

Rows Claude Code draws about the account of whoever runs this (how much of a plan's limit is used, 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'

shot <file> <rows>: the last <rows> rows of the screen that have something on them, as an SVG. The font is a list, not an embedded file: a browser takes each character from the first font that has it, 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}

idle: the turn is over (the band shows its end).

87idle() { wait_for 120 'turn end: '; sleep 1; }
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'

The scratch project is new, so Claude Code asks whether to trust it, with "No, exit" selected. Enter is pressed only once the screen shows the cursor on "Yes": a Down the dialog was not ready for would 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

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
  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
  1. 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
  1. 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
  1. 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: "

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