Claude shows its finished work right in your terminal: screenshots, videos of it clicking through your app, and synced before/after QA

<img src="docs/homepage.jpg" alt="A recording of the Tern homepage plays inside Claude Code, with the list of what changed beside it" width="100%">
<h1 align="center">claude-show-your-work</h1>
<a href="https://github.com/iamumeransari/claude-show-your-work/stargazers"><img src="https://img.shields.io/github/stars/iamumeransari/claude-show-your-work?style=flat&logo=github&label=Stars&color=E3B341" alt="GitHub stars"></a> <a href="#install"><img src="https://img.shields.io/badge/Claude_Code-plugin-D97757?logo=claude&logoColor=white" alt="Claude Code plugin"></a> <a href="#install"><img src="https://img.shields.io/badge/macOS_%7C_Linux_%7C_Windows-555555" alt="macOS, Linux and Windows"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/iamumeransari/claude-show-your-work?color=22C55E" alt="MIT license"></a>
Run these two commands in Claude Code:
/plugin marketplace add https://github.com/iamumeransari/claude-show-your-work.git#releases
/plugin install show-your-work@iamumeransari
It works on macOS, Linux and Windows. You need a terminal that supports the kitty graphics protocol, like Ghostty or Warp, and Claude Code 2.1.286 or later. Videos also need ffmpeg: install it with your package manager (brew install ffmpeg on macOS), or ask Claude to install it.
When the install finishes, a short onboarding shows you around. Run /show-your-work at any time to change your settings.
Claude shows you its work in the chat whenever it builds or changes something visual. You can also ask for it directly:
When Claude builds a feature or fixes a bug, it tests the change with Playwright, records the run and plays the recording in the chat, with everything you need to review the work in one place:
<img src="docs/concepts.jpg" alt="Four landing page concepts for Tern shown in the chat" width="49%"> <img src="docs/breakpoints.jpg" alt="The Tern homepage at desktop, tablet and phone sizes shown in the chat" width="49%">
Whatever Claude designs, you see it right in the chat: landing pages, app screens, components, design concepts, slides, charts and generated images. Ask for a few directions and they appear side by side, then keep iterating on the one you like.
<img src="docs/persona.jpg" alt="An ad persona in the chat" width="49%"> <img src="docs/reel.jpg" alt="The ad made from it, playing in the chat" width="49%">
Beyond product QA, Claude can show you a video of almost anything it works on. Building a website and want to see how it feels as you scroll from top to bottom? Just ask, and the recording plays in the chat, animations and all.
Making an ad, an animation or a launch video? Each cut plays right in the chat, in a full-featured video player:
show-your-work works in any terminal that supports the kitty graphics protocol, on macOS, Linux and Windows.
/show-your-work.Turn on pictures adds one line to your shell profile (~/.zshrc, ~/.bash_profile or ~/.bashrc, or fish's config.fish) that sets CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 only inside that terminal. Turning it off removes the line.
Yes, Claude Code can show images in the terminal with the show-your-work plugin. Once it's installed, Claude shows screenshots, designs, charts and other images right in the chat. It works in terminals that support the kitty graphics protocol, like Ghostty and Warp.
Yes, Claude Code can play videos in the terminal with the show-your-work plugin. It plays screen recordings, animations, ads and launch videos inside the Claude Code chat. It comes with a feature-rich video player, with scrubbing, skip controls, clickable timestamps and side-by-side comparisons.
You can see what Claude Code built by installing the show-your-work plugin. Whenever Claude builds or changes something visual, it takes a screenshot or records it and shows you the result in the chat. You don't have to switch to a browser and find the page yourself.
Yes, Claude Code can test your app and show you a recording with the show-your-work plugin. Claude tests the features it builds with Playwright, records each run and plays the recording in the chat. The steps are listed beside the video with timestamps. Each check turns green or red as the video reaches it.
Yes, Claude Code can show you a before and after of a change or bug fix with the show-your-work plugin. Claude records the same Playwright steps on the old version and the new one. Compare plays both recordings side by side, in sync. Each test check turns green or red as the video reaches it.
Yes, show-your-work is a Claude Code plugin for visual QA. It records Claude's Playwright test runs and plays them in the chat. Each recording comes with clickable change markers and checks that turn green or red as the video plays. For a change or bug fix, it plays the before and after side by side, in sync.
The easiest way to check that Claude Code actually fixed a bug is to have it record the fix with show-your-work. Ask Claude to fix the bug and show you the before and after. The old and new versions play side by side, in sync, with the checks that now pass marked in green.
show-your-work goes beyond viewing images in the terminal. Its main strength is visual QA. It plays synced before-and-after Playwright recordings, lists the changes you can click to jump to, and turns each check green or red as the video plays. You can review a feature or a fix without leaving Claude Code.
No, show-your-work barely uses any extra tokens, because it's built from the ground up to be both token and memory efficient. Images and videos are drawn straight into your terminal with the kitty graphics protocol, without using any tokens. Videos are also decoded and streamed efficiently, so very little memory is used.
The show-your-work plugin works in terminals that support the kitty graphics protocol, like Ghostty and Warp, on macOS, Linux and Windows.
Yes, show-your-work is free and open source under the MIT license.
No, you don't need to update it yourself. The plugin updates itself, so you're never left behind. You can choose between Stable and Latest releases during the onboarding, or any time with /show-your-work.
You can see the onboarding again by running /show-your-work tour. It covers recordings, before and after, version history and the settings.
No, it doesn't take up much space. It keeps recent pictures and videos so you can flip back through them, and clears out the oldest once they add up to about 1 GB.
You choose whether to share anonymous usage data. You can make that choice during the onboarding, or change it any time with /show-your-work. It never collects any sensitive data whatsoever. The details are in the Privacy policy.
Here's everything the plugin does on your computer, so you know exactly what you're installing. Your pictures and videos never leave your machine.
To get pictures and videos ready, it uses ffmpeg and ffprobe (turning a video into frames and reading its length) and, on macOS, sips (converting a picture to PNG). When you press View, Show in Finder or Save to Downloads, it opens the file with open on macOS or xdg-open on Linux. On macOS, mdfind helps /img find an image you pasted, and inside tmux it asks tmux whether pictures can get through.
A few short scripts run with sh and perl. They find ffmpeg and your home folder, read the terminal's cell size so pictures fit, tidy the plugin's temp folder, open a viewer on Linux, and switch between the Stable and Latest channels. It also uses cp, mkdir, rm, cat and touch for its own temp files, and claude plugin to install its own updates from this repository, at most once a day. You can turn updates off. The updater will be turned off after the plugin moves to the Claude Code plugin directory, which keeps it up to date instead.
It keeps these files:
$TMPDIR/claude-show-your-work-<your user id>), and anything older than a week is cleaned up..show-your-work/ in your project, so you can look back through versions.~/Downloads.SHOW_YOUR_WORK_DEBUG=1, it writes a debug.log in the temp folder.Once a day it reads the plugin's latest version number from raw.githubusercontent.com.
If Usage data is set to share in /show-your-work, it sends anonymous usage counts to us.i.posthog.com. These are event names and counts, plus the plugin and Claude Code versions, your terminal, your OS and whether ffmpeg is installed, under a random install id. They never include file names, paths, prompts, anything you type or your IP address. The key in the code is PostHog's public key, which can only send events.
Inside Claude Code, it uses these hooks:
prompt.compose adds one short line to Claude's instructions, telling it this terminal can show pictures (or why it can't). It only does this when the plugin's tools are available.prompt.submit closes the settings when you send a message and remembers the names of images you paste, so /img can find them. Your message isn't changed.tool.call handles the plugin's own show_image and show_video tools. It never touches other tools.command.run runs /img and /show-your-work. Right after you install, it runs /show-your-work tour once to show you around.ui.render draws the plugin's player, notices, settings and tour.session.start and session.end set up the temp folder and terminal at the start, and send any waiting usage counts at the end.To report a bug or suggest a change, open an issue.
The plugin gives Claude two tools, show_image and show_video. A basic call needs only a path. For a before and after, Claude also passes the old file and the test results:
{
"path": "qa/after.webm", // the run on the fixed version
"compare": "qa/before.webm", // the same steps on the old version
"title": "Pricing page",
"url": "http://localhost:3000/pricing",
"changes": [ // what changed, and the second it shows up
{ "title": "Annual toggle sets prices", "at": 3.2 },
{ "title": "Plan buttons line up", "at": 5.4 }
],
"summary": [ // the test results, at most three lines
{ "label": "Billing: toggle works", "ok": true, "passed": 1, "total": 1 },
{ "label": "Layout: plans, buttons", "ok": true, "passed": 3, "total": 3 }
]
}
Videos are cut into frames at up to 12 per second, at the width the window shows them (at most 2400 px), with a cap of 3,600 frames (five minutes at full rate). To open a file yourself, run /img path/to/file.png.
To work on the plugin, edit the modules in hooks/ (register.tsx is the entry) and run /reload-plugins to load your changes. Before sending a change, run claude plugin validate . and claude plugin test ..
show-your-work is actively maintained. It is the first plugin to bring pictures, videos and automated visual QA with Playwright recordings into the Claude Code terminal. Its launch video has been viewed more than 275,000 times. It has since become the popular choice among developers who want to see what Claude builds without leaving the terminal. It's listed in some of the biggest Claude Code plugin catalogs and currently ranks among the top trending plugins for Claude Code.
If you want Claude Code to show you screenshots, screen recordings or before-and-after comparisons of its work without leaving the terminal, this plugin is built for exactly that.
MIT. Photography in the examples is from Unsplash, used under the Unsplash License. Privacy.
hooks/bundle.tsx 3909 lines1// Generated by tools/bundle.mjs from the modules in hooks/. Edit those, then
2// run `node tools/bundle.mjs`; this file is what hooks.json loads.
3
4import type { EngineInterface, Register, Timer } from 'claude-code'
5import type { Check, Frames, KeyChange, Overlay, Shown, Summary } from '../types'
6import { atom, read, update } from 'claude-code'
7
8// ── layout.ts ─────────────────────────────────────────────────────────────
9// Sizing, from the room a player has to the box it draws and the pixels its
10// picture needs, and the helpers that fit text to a width.
11
12
13// The window's fill: a step lighter than a dark terminal, so it reads as one panel.
14const PANEL = '#2c2e35'
15// The QA report beside the picture, and the width the window needs for it.
16export const SIDE = 40
17// The sidebar's fill: darker than the window, so the report stands apart.
18export const SIDEBAR = '#1d1e23'
19const WIDE_ENOUGH = 110
20
21// One path from the room a player has to the box it draws and the pixels its
22// picture needs:
23// layoutOf(room) the space: columns and rows for the picture, report beside or below
24// targetBox(w, h, ...) the box a picture of that shape fills in it
25// pixelsFor(box, cell) the screen pixels that box needs (PX_MIN to PX_MAX)
26// drawBox(w, h, ...) the box drawn: the target, cut down to the picture's own pixels
27// The terminal draws a picture at its own pixel size at most, so pictures and
28// frames are made as wide as their box (frameWidth when prepared, sharpen
29// when a player needs more), and drawBox keeps a picture that's still short
30// of pixels whole instead of small in a big box.
31const PX_MIN = 320
32export const PX_MAX = 2400
33export type Room = { columns: number; rows: number; isPane: boolean; hasReport: boolean }
34export type Layout = { isWide: boolean; inner: number; maxCols: number; maxRows: number }
35export function layoutOf(room: Room): Layout {
36 const isWide = room.hasReport && room.columns - 8 >= WIDE_ENOUGH
37 // The window's border and padding; a pane has no row border of its own.
38 const inner = room.columns - (room.isPane ? 6 : 10)
39 const space = inner - (isWide ? SIDE + 3 : 0)
40 const maxCols = Math.max(1, Math.max(Math.min(space, 24), Math.min(space, room.isPane ? 220 : 120)))
41 // Inline, a picture takes at most 55% of the screen's height; a pane keeps 12 rows for its controls.
42 const maxRows = room.isPane ? Math.max(6, room.rows - 12) : Math.max(8, Math.min(40, Math.floor(room.rows * 0.55)))
43 return { isWide, inner, maxCols, maxRows }
44}
45export const targetBox = (width: number, height: number, layout: Layout, px: Cell | null) =>
46 fit(width, height, layout.maxCols, layout.maxRows, px, false)
47export const drawBox = (width: number, height: number, layout: Layout, px: Cell | null) =>
48 fit(width, height, layout.maxCols, layout.maxRows, px, true)
49// Enough for the box's width and, given the picture's shape, its height:
50// boxes are whole rows, so a wide picture sized by width alone falls short.
51export const pixelsFor = (box: { columns: number; rows?: number }, px: Cell | null, aspect = 0) =>
52 px ? Math.max(PX_MIN, Math.min(PX_MAX, Math.ceil(Math.max(box.columns * px.width, (box.rows ?? 0) * px.height * aspect) / 2) * 2)) : 1600
53
54// Fits a picture in a box of cells, its own shape. With the cell's size in
55// pixels known and `isCapped`, never past the picture's own pixels; unknown,
56// a cell is about twice as tall as wide.
57export function fit(width: number, height: number, maxCols: number, maxRows: number, px?: Cell | null, isCapped = true) {
58 if (px) {
59 const scale = Math.min(isCapped ? 1 : Number.POSITIVE_INFINITY, (maxCols * px.width) / width, (maxRows * px.height) / height)
60 // A hair over, so a box that fits exactly (38 rows, computed as 37.999…) keeps its last row.
61 const rows = Math.max(1, Math.min(255, Math.floor((height * scale) / px.height + 1e-9)))
62 const shown = isCapped ? Math.min(height, rows * px.height) : rows * px.height
63 const columns = Math.max(1, Math.min(255, maxCols, Math.ceil((shown * width) / height / px.width)))
64 return { columns, rows }
65 }
66 let columns = Math.max(1, Math.min(255, maxCols))
67 let rows = Math.ceil((columns * height) / width / 2)
68 if (rows > maxRows) {
69 rows = maxRows
70 columns = Math.max(1, Math.ceil((rows * 2 * width) / height))
71 }
72
73 return { columns: Math.min(255, columns), rows: Math.max(1, Math.min(255, rows)) }
74}
75
76// The width a picture or clip is made at when it's prepared: what an inline
77// player in this terminal needs (see layoutOf). 1600 px at most while unmeasured.
78export function frameWidth(width: number, height: number, term: { columns: number; rows: number } | null, px: Cell | null) {
79 if (!term || !px || !width || !height) return Math.min(1600, width)
80 const layout = layoutOf({ columns: term.columns, rows: term.rows, isPane: false, hasReport: false })
81 return pixelsFor(targetBox(width, height, layout, px), px, width / height)
82}
83
84// The drawn box for `room` columns already free of chrome (the pair view).
85export function sizeFor(width: number, height: number, room: number, rows: number, isPane: boolean, px: Cell | null = state.cell) {
86 return drawBox(width, height, layoutOf({ columns: room + (isPane ? 6 : 10), rows, isPane, hasReport: false }), px)
87}
88
89// The terminal's cell in pixels, from the size its tty reports; null where it
90// reports none (tmux, a relay), and measured again when the window changes.
91export type Cell = { width: number; height: number }
92export const CELL_SH = `perl -e 'open(T, "</dev/tty") or exit 1; my $w = " " x 8; ioctl(T, ($^O eq "darwin" ? 0x40087468 : 0x5413), $w) or exit 1; print join(" ", unpack("S4", $w))'`
93// The tty's size is in points; pictures draw in screen pixels, `scale` to a
94// point (2 on Retina).
95export function cellOf(stdout: string, scale = 1): Cell | null {
96 const [rows, columns, x, y] = stdout.trim().split(/\s+/).map(Number)
97 return rows && columns && x && y ? { width: (x / columns) * scale, height: (y / rows) * scale } : null
98}
99const SCALE_SH = `osascript -l JavaScript -e 'ObjC.import("AppKit"); $.NSScreen.mainScreen.backingScaleFactor'`
100export async function measureCells($: EngineInterface) {
101 const [r, s] = await Promise.all([
102 $.process.run(['sh', '-c', CELL_SH]).catch(() => null),
103 state.isMac ? $.process.run(['sh', '-c', SCALE_SH]).catch(() => null) : null,
104 ])
105 const scale = Number(s?.stdout.trim()) || 1
106 state.cell = r?.exitCode === 0 ? cellOf(r.stdout, scale) : null
107 const [rows, columns] = (r?.exitCode === 0 ? r.stdout : '').trim().split(/\s+/).map(Number)
108 state.termSize = rows && columns ? { columns, rows } : null
109}
110// Players of different widths each ask: one measure a second at most, the
111// last ask still measured.
112let measuredAt = 0
113export async function remeasure($: EngineInterface): Promise<boolean> {
114 const wait = measuredAt + 1000 - (await $.clock.now())
115 if (wait > 0) await $.clock.sleep(wait)
116 measuredAt = await $.clock.now()
117 const was = state.cell
118 await measureCells($)
119 return was?.width !== state.cell?.width || was?.height !== state.cell?.height
120}
121
122// The viewport is the whole screen; a docked pane is narrower and shorter, so
123// it sizes to its own body.
124export type PaneProps = { bodyColumns?: number; bodyRows?: number; scroll?: { bodyRows?: number } }
125export const paneSize = (viewport: { columns: number; rows: number } | undefined, props: PaneProps) => {
126 if (!viewport) return viewport
127 const rows = props.bodyRows ?? props.scroll?.bodyRows
128 return { columns: props.bodyColumns || viewport.columns, rows: rows || viewport.rows }
129}
130
131export const clock = (s: number) => `${Math.floor(Math.max(0, s) / 60)}:${String(Math.floor(Math.max(0, s) % 60)).padStart(2, '0')}`
132
133// One line, whatever the length: cut at a word, with … where it was cut.
134export function oneLine(text: string, width: number): string {
135 const flat = text.replace(/\s+/g, ' ').trim()
136 width = Math.max(4, Math.floor(width))
137 if (flat.length <= width) return flat
138 const cut = flat.slice(0, width - 1)
139 const space = cut.lastIndexOf(' ')
140 return `${(space > width / 2 ? cut.slice(0, space) : cut).replace(/[\s,.;:]+$/, '')}…`
141}
142
143// A tab name short enough that every tab fits on one line and stays clickable.
144export const tabLabel = (text: string, max = 12) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text)
145
146// Words wrapped to lines of at most width, so the whole text is always shown.
147export function wrapWords(text: string, width: number): string[] {
148 width = Math.max(1, Math.floor(width))
149 const lines: string[] = []
150 let line = ''
151 for (const word of text.split(/\s+/).filter(Boolean)) {
152 for (let w = word; w; ) {
153 const piece = w.length > width ? w.slice(0, width) : w
154 w = w.slice(piece.length)
155 if (!line) line = piece
156 else if (line.length + 1 + piece.length <= width) line += ` ${piece}`
157 else {
158 lines.push(line)
159 line = piece
160 }
161 }
162 }
163 return lines.length || line ? [...lines, line].filter(Boolean) : ['']
164}
165
166// Splits wrapped items into pages that each fit rows; an item never splits.
167export function pagesOf<T>(items: { item: T; rows: number }[], rows: number): T[][] {
168 const pages: T[][] = [[]]
169 let used = 0
170 for (const { item, rows: need } of items) {
171 if (used + need > rows && pages.at(-1)!.length) {
172 pages.push([])
173 used = 0
174 }
175 pages.at(-1)!.push(item)
176 used += need
177 }
178 return pages
179}
180
181// ── telemetry.ts ──────────────────────────────────────────────────────────
182// Anonymous usage stats, the button presses they count, and the local
183// playback timing log.
184
185
186// A button press, by name only. False, so it chains: act('save') || keep().
187export const act = (action: string): false => {
188 track('action', { action })
189 return false
190}
191// SHOW_YOUR_WORK_DEBUG=1: playback timings, a line per event, in TMP/debug.log.
192const debugLines: string[] = []
193export const debug = (line: string) => { if (state.isDebug) debugLines.push(`${Date.now()} ${line}`) }
194export async function flushDebug($: EngineInterface) {
195 if (!debugLines.length) return
196 await $.fs.write(`${state.TMP}/debug.log`, `${debugLines.slice(-20000).join('\n')}\n`).catch(() => undefined)
197}
198
199// Anonymous usage stats, sent to PostHog: what kind of thing happened and how
200// often, under a random id made once per install. Never a file name, path,
201// URL, title, prompt or anything typed. Off with SHOW_YOUR_WORK_TELEMETRY=0 or
202// DO_NOT_TRACK=1, and nothing is sent while POSTHOG_PROJECT is empty.
203// PostHog's public project id for this plugin: it can only add events, never
204// read them, so it ships. The address is written out at the call, so the
205// directory review can read where the events go.
206const POSTHOG_PROJECT = 'phc_mLGFguVcH3Tei4txwqXFPU4xBgFDKThEZncahTv3LS3W'
207// Events wait here and go out together, at most once a minute and at exit.
208const FLUSH_MS = 60_000
209const MAX_QUEUED = 500
210
211export type Props = Record<string, string | number | boolean | null | undefined | Record<string, string | number | boolean | undefined>>
212export type Event = { event: string; properties: Props; timestamp: string }
213let installId = ''
214let base: Props = {}
215let isFlushing = false
216
217// A made-up name for an install, the same every time for one id, so profiles
218// read as "Swift Otter" rather than a UUID. Nothing in it comes from the person.
219const ADJECTIVES = ['Swift', 'Quiet', 'Brave', 'Clever', 'Sunny', 'Lucky', 'Bold', 'Calm', 'Eager', 'Gentle', 'Happy', 'Jolly', 'Keen', 'Lively', 'Mellow', 'Nimble', 'Plucky', 'Rapid', 'Snappy', 'Tidy', 'Witty', 'Zesty', 'Cosmic', 'Fuzzy']
220const ANIMALS = ['Otter', 'Falcon', 'Panda', 'Fox', 'Koala', 'Lynx', 'Heron', 'Badger', 'Gecko', 'Owl', 'Puffin', 'Raccoon', 'Seal', 'Tiger', 'Wombat', 'Yak', 'Bison', 'Crane', 'Dolphin', 'Ferret', 'Hedgehog', 'Lemur', 'Moose', 'Quokka']
221export function nicknameOf(id: string): string {
222 let hash = 0
223 for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
224 const n = Math.floor(hash / ADJECTIVES.length) % 1000
225
226 return `${ADJECTIVES[hash % ADJECTIVES.length]} ${ANIMALS[Math.floor(hash / 1000) % ANIMALS.length]} ${n}`
227}
228
229// An installed copy runs from Claude Code's plugin cache. One loaded from a
230// folder (--plugin-dir) is someone working on the plugin: it sends nothing.
231export const isInstalledCopy = (root: string) => /\/plugins\/cache\//.test(root)
232
233// SHOW_YOUR_WORK_TELEMETRY=0, DO_NOT_TRACK=1 or CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: nothing is sent.
234export const isStatsBlocked = (optOut?: string, dnt?: string, quiet?: string) =>
235 /^(0|false|off|no)$/i.test(optOut ?? '') || /^(1|true)$/i.test(dnt ?? '') || !!quiet
236
237export async function startTelemetry($: EngineInterface, context: Props) {
238 if (!POSTHOG_PROJECT || !isInstalledCopy($.plugin.root)) return
239 const optOut = await $.env.get('SHOW_YOUR_WORK_TELEMETRY')
240 const dnt = await $.env.get('DO_NOT_TRACK')
241 const quiet = await $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')
242 if (isStatsBlocked(optOut, dnt, quiet)) return
243 // Turned back on in the same session: the timer is already running.
244 if (installId) return void (state.isOn = true)
245 installId = String((await $.store.get('installId')) ?? '')
246 const isNew = !installId
247 if (isNew) {
248 installId = crypto.randomUUID()
249 await $.store.set('installId', installId)
250 }
251 state.isOn = true
252 // Each install is one anonymous person, keyed by its random id: the profile
253 // holds the setup it runs on, never anything about who uses it.
254 // No location, ever: no IP is sent, and PostHog is told not to look one up.
255 base = { ...context, $ip: null, $geoip_disable: true }
256 const profile = { $set: { ...context, name: nicknameOf(installId), update_channel: settings.stable_updates ? 'stable' : 'latest', usage_data: 'on' }, $set_once: { first_plugin_version: String(context.plugin_version ?? "") } }
257 if (isNew) track('installed', profile)
258 track('session_started', profile)
259 $.clock.every(FLUSH_MS, () => void flush($))
260}
261
262// Counts and kinds only: callers pass no names, paths or text.
263export function track(event: string, properties: Props = {}) {
264 if (!state.isOn) return
265 if (state.queue.length >= MAX_QUEUED) state.queue.shift()
266 state.queue.push({ event, properties: { ...base, ...properties }, timestamp: new Date().toISOString() })
267}
268
269export async function flush($: EngineInterface) {
270 if (!state.isOn || isFlushing || !state.queue.length) return
271 isFlushing = true
272 const batch = state.queue
273 state.queue = []
274 try {
275 const r = await $.http.fetch('https://us.i.posthog.com/batch/', {
276 method: 'POST',
277 headers: { 'content-type': 'application/json' },
278 body: JSON.stringify({ api_key: POSTHOG_PROJECT, batch: batch.map(e => ({ ...e, distinct_id: installId })) }),
279 })
280 // A refused batch is dropped, not retried: stats are never worth a backlog.
281 if (!r.ok && r.status >= 500) state.queue = [...batch, ...state.queue].slice(-MAX_QUEUED)
282 } catch {
283 state.queue = [...batch, ...state.queue].slice(-MAX_QUEUED)
284 } finally {
285 isFlushing = false
286 }
287}
288
289// ── media.ts ──────────────────────────────────────────────────────────────
290// Getting pictures and videos ready to draw: conversion, frames cut a chunk
291// at a time, the side-by-side clip of two runs, and the frame cache.
292
293
294
295// Deletes the oldest frame folders until the cache is under $1 KB, keeping the
296// newest two and the ones named in $2; only inside a folder this user owns.
297export const TRIM_SH = [
298 '[ -d "$0" ] && [ ! -L "$0" ] && [ -O "$0" ] && cd -P "$0" 2>/dev/null || exit 0',
299 // Only the folders the plugin names (v-<key>, v<n>-<time>, p-<key>,
300 // p2-<key>), one per line: a picture named by the user never matches.
301 'all=$(ls -dtr -- v* p* 2>/dev/null | grep -E "^(v-[0-9a-f]+|v[0-9]+-[0-9]+|p2?-[0-9a-f]+)$"); n=$(printf "%s\\n" "$all" | grep -c .)',
302 'printf "%s\\n" "$all" | head -n $((n > 2 ? n - 2 : 0)) | while IFS= read -r d; do',
303 ' [ -n "$d" ] && [ -d "$d" ] || continue',
304 ' [ "$(du -sk . | cut -f1)" -le "$1" ] && break',
305 ' case " $2 " in *" $d "*) continue ;; esac',
306 ' [ -L "$d" ] || rm -rf "./$d"',
307 'done; true',
308].join('\n')
309// Deletes anything in the cache older than a week, without following links.
310export const CLEAN_SH = [
311 '[ -d "$0" ] && [ ! -L "$0" ] && [ -O "$0" ] || exit 0',
312 'find -P "$0" -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} + 2>/dev/null; true',
313].join('\n')
314export const VIDEO = /\.(mp4|mov|m4v|webm|mkv|avi|gif)$/i
315// Frames a clip is cut into at most, and the rate it plays at below that.
316// A clip cut whole (no known length) keeps every frame on disk at once, so it
317// stays at MAX_FRAMES. One of known length is cut as it plays and only a few
318// chunks are ever on disk, so it can run to MAX_STREAMED at the full rate.
319const MAX_FRAMES = 900
320const MAX_STREAMED = 3600
321const MAX_FPS = 12
322
323// A typed or dragged-in path: quotes, backslash-escaped spaces and ~ allowed.
324export function expand(path: string, base: string, homeDir: string): string {
325 let p = path.trim().replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1')
326 if (p === '~' || p.startsWith('~/')) p = homeDir + p.slice(1)
327 if (p.startsWith('/')) return p
328
329 return `${base.replace(/\/$/, '')}/${p}`
330}
331
332// A name with its look-alike spaces made plain: macOS writes a narrow
333// no-break space before AM/PM in screenshot names, which a typed path lacks.
334export const looseName = (name: string) =>
335 name.normalize('NFC').replace(/[ - ]/g, ' ')
336
337// The file a path means: as given when it exists, else the one entry in its
338// folder whose name matches once spaces are made plain.
339export async function locate($: EngineInterface, raw: string): Promise<string> {
340 const path = expand(raw, state.cwd, state.home)
341 if (await $.fs.stat(path).then(() => true, () => false)) return path
342 const cut = path.lastIndexOf('/')
343 const dir = path.slice(0, cut) || '/'
344 const want = looseName(path.slice(cut + 1))
345 const hit = (await $.fs.list(dir).catch(() => [])).find(f => looseName(f.name) === want)
346
347 return hit ? `${dir.replace(/\/$/, '')}/${hit.name}` : path
348}
349
350export const frameFile = (frames: Frames, i: number) => `${frames.dir}/f${String(i + 1).padStart(5, '0')}.png`
351
352// How fast to sample a clip of known length so it stays under MAX_STREAMED.
353export const sampleRate = (duration: number) =>
354 Math.max(1, Math.min(MAX_FPS, Math.floor(MAX_STREAMED / Math.max(duration, 0.1))))
355
356// A PNG's size from its header (bytes 16 to 24), without any tool.
357export function pngDims(base64: string): { width: number; height: number } | null {
358 const b = Uint8Array.from(atob(base64.slice(0, 44)), c => c.charCodeAt(0))
359 if (b.length < 24 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return null
360 const at = (i: number) => (((b[i] ?? 0) << 24) | ((b[i + 1] ?? 0) << 16) | ((b[i + 2] ?? 0) << 8) | (b[i + 3] ?? 0)) >>> 0
361
362 return { width: at(16), height: at(20) }
363}
364
365// Finds a pasted image's original file by name with Spotlight (macOS).
366export async function findPasted($: EngineInterface, name: string): Promise<string | null> {
367 if (name.startsWith('/')) return name
368 if (!state.isMac) return null
369 const found = await $.process.run(['mdfind', '-name', name])
370
371 return found.stdout.split('\n').find(line => line.endsWith(`/${name}`)) ?? null
372}
373
374export async function pngSize($: EngineInterface, png: string) {
375 if (!state.isMac) {
376 const d = pngDims(String((await $.fs.read(png, { as: 'bytes' }) as unknown as { base64: string }).base64 ?? ''))
377 if (!d || !d.width || !d.height) throw new Error(`not an image: ${png}`)
378 return d
379 }
380 const dims = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png])
381 const width = Number(/pixelWidth:\s*(\d+)/.exec(dims.stdout)?.[1] ?? 0)
382 const height = Number(/pixelHeight:\s*(\d+)/.exec(dims.stdout)?.[1] ?? 0)
383 if (!width || !height) throw new Error(`not an image: ${png}`)
384
385 return { width, height }
386}
387
388// Frames on disk are a cache: past 500 MB the oldest folders go, and a window
389// that plays one again decodes it again (ensureChunk). The newest two stay.
390const FRAMES_CAP_KB = 500_000
391// Folders this session is playing are never trimmed.
392export function trimFrames($: EngineInterface) {
393 const inUse = [...watching.keys()].map(d => d.split('/').pop()!).join(' ')
394 return $.process.run(['sh', '-c', TRIM_SH, state.TMP, String(FRAMES_CAP_KB), inUse]).catch(() => undefined)
395}
396
397// A recording often opens on a blank page while the browser loads: white,
398// off-white, grey or dark, maybe with a small spinner or logo. Any frame that
399// is nearly one flat tone counts as blank; the clip starts at the first real one.
400async function whiteLead($: EngineInterface, real: string, duration: number): Promise<number> {
401 const r = await $.process.run([state.ffmpeg, '-v', 'info', '-t', '8', '-i', real,
402 '-vf', 'fps=10,scale=160:-2,signalstats,metadata=print:key=lavfi.signalstats.YLOW:key=lavfi.signalstats.YHIGH',
403 '-an', '-f', 'null', '-']).catch(() => null)
404 return r ? leadFrom(r.stderr, duration) : 0
405}
406
407// The blank opening in that log: the time of the first frame whose 10th to
408// 90th percentile brightness spreads wider than a flat page does, if that
409// leaves something after it.
410export function leadFrom(log: string, duration: number): number {
411 let t = 0, low = -1
412 for (const line of log.split('\n')) {
413 const pts = line.match(/pts_time:([\d.]+)/)
414 if (pts) { t = Number(pts[1]); low = -1; continue }
415 const lo = line.match(/YLOW=([\d.]+)/)
416 if (lo) { low = Number(lo[1]); continue }
417 const hi = line.match(/YHIGH=([\d.]+)/)
418 if (hi && low >= 0) {
419 if (Number(hi[1]) - low > 24) {
420 const lead = Math.round(t * 100) / 100
421 return lead > 0 && (!(duration > 0) || lead < duration - 0.5) ? lead : 0
422 }
423 low = -1
424 }
425 }
426 return 0
427}
428
429// Times given on the clip as it was, moved to the frames that start after
430// its white opening.
431export function shiftTimes<T extends { at?: number }>(list: T[] | undefined, lead: number): T[] | undefined {
432 if (!list || !lead) return list
433 return list.map(c => (c.at === undefined ? c : { ...c, at: Math.max(0, +(c.at - lead).toFixed(2)) }))
434}
435
436// What to tell Claude and the person when a video can't be cut into frames.
437export const NO_FFMPEG =
438 "videos need ffmpeg, and it isn't installed. Pictures still work. Install it with `brew install ffmpeg` " +
439 '(Claude can run that for you), then restart Claude Code. No Homebrew? Install it first from https://brew.sh.'
440
441// On Linux ffmpeg also turns pictures other than PNG into PNG.
442export const NO_FFMPEG_LINUX =
443 "videos and pictures other than PNG need ffmpeg, and it isn't installed. Install it with your package manager, " +
444 'for example `sudo apt install ffmpeg` or `sudo dnf install ffmpeg` (Claude can run that for you), then restart Claude Code.'
445export const noFfmpeg = (mac: boolean) => (mac ? NO_FFMPEG : NO_FFMPEG_LINUX)
446export const FFMPEG_PROMPT_LINUX =
447 "Install ffmpeg for me with this machine's package manager (for example `sudo apt install ffmpeg`). Then show it again."
448
449// What the person pastes back so Claude installs ffmpeg and tries again.
450export const FFMPEG_PROMPT =
451 'Install ffmpeg for me with `brew install ffmpeg`. If Homebrew isn\'t installed, tell me how to install it first. Then show the video again.'
452
453// A failure as the person and Claude see it: a box in place of the picture,
454// one plain message for Claude, and a prompt to paste back. Never raw tool
455// output, never the plugin's internal copy of a file.
456export type NoticeKind = 'missing' | 'not_image' | 'broken_video' | 'no_picture' | 'no_ffmpeg' | 'other'
457export type Notice = { kind: NoticeKind; title: string; text: string; message: string; prompt: string }
458
459// How a failure reads, from whatever the tools said; `path` is the person's own.
460export function noticeOf(err: unknown, path: string, homeDir = ''): Notice {
461 const raw = String((err as Error)?.message ?? err)
462 const shown = homeDir && path.startsWith(`${homeDir}/`) ? `~${path.slice(homeDir.length)}` : path
463 const name = path.split('/').pop() || path
464 if (raw.includes(NO_FFMPEG_LINUX)) return {
465 kind: 'no_ffmpeg', title: 'This needs ffmpeg', message: NO_FFMPEG_LINUX, prompt: FFMPEG_PROMPT_LINUX,
466 text: "ffmpeg isn't installed, so videos and pictures other than PNG can't show here. Copy the prompt and paste it to Claude to install it, or install it yourself, for example `sudo apt install ffmpeg`.",
467 }
468 if (raw.includes(NO_FFMPEG)) return {
469 kind: 'no_ffmpeg', title: 'Videos need ffmpeg', message: NO_FFMPEG, prompt: FFMPEG_PROMPT,
470 text: "ffmpeg isn't installed, so this video can't play here. Pictures still work. Copy the prompt and paste it to Claude to install it, or run `brew install ffmpeg` yourself. No Homebrew? Get it from brew.sh first.",
471 }
472 if (/does not contain any stream|matches no streams|no frames in|Output file is empty/i.test(raw)) return {
473 kind: 'no_picture', title: 'This video has no picture',
474 text: `${name} has sound but no picture, so there's nothing to play.`,
475 message: `${shown} has no video track (sound only), so there's nothing to play. Don't retry it; show a file with a picture instead.`,
476 prompt: `${shown} has no picture, only sound. Find the right video and show it to me again.`,
477 }
478 if (/ffmpeg could not read|Invalid data|moov atom|could not find codec|Error opening input/i.test(raw)) return {
479 kind: 'broken_video', title: "This video can't be read",
480 text: `${name} couldn't be opened as a video. It may be cut off, still being written, or not really a video.`,
481 message: `${shown} couldn't be read as a video: it's damaged, unfinished (still recording or cut off), or not a video. Re-export or re-record it, then show it again.`,
482 prompt: `${shown} couldn't be read as a video. Check it, re-export or re-record it if it's broken, then show it to me again.`,
483 }
484 if (/could not convert|not an image|Cannot extract image|sips/i.test(raw)) return {
485 kind: 'not_image', title: "This isn't a picture",
486 text: `${name} isn't a picture, so it can't be shown. Pictures can be PNG, JPEG, WebP, GIF or TIFF.`,
487 message: `${shown} isn't a picture (show_image opens PNG, JPEG, WebP, GIF and TIFF). Show an image file, or use show_video for a video.`,
488 prompt: `${shown} isn't a picture. Show me the right file instead.`,
489 }
490 if (/ENOENT|no such file|not found/i.test(raw)) return {
491 kind: 'missing', title: 'File not found',
492 text: `There's no file at ${shown}.`,
493 message: `There's no file at ${shown}. Check the path (it may have moved or not been saved yet), then show it again.`,
494 prompt: `There's no file at ${shown}. Find the right file and show it to me again.`,
495 }
496 const detail = (raw.split('\n')[0] ?? '').replace(/\/(?:private\/)?tmp\/\S+|\S*\/\.show-your-work\/\S+/g, name).slice(0, 160)
497 return {
498 kind: 'other', title: "Couldn't show it",
499 text: `${name} couldn't be shown: ${detail}`,
500 message: `${shown} couldn't be shown: ${detail}`,
501 prompt: `${shown} couldn't be shown (${detail}). Look into it and show it to me again.`,
502 }
503}
504
505// The usage-stats reason for a failure, as a kind.
506export const REASONS: Record<NoticeKind, string> = {
507 missing: 'missing_file', no_ffmpeg: 'video_tools', not_image: 'unreadable', broken_video: 'unreadable', no_picture: 'unreadable', other: 'other',
508}
509
510// Cuts a clip into PNG frames the terminal can swap through.
511// A file as it is now: its path, size and modified time. The same unchanged
512// file shown again maps to the same frames folder and skips the decode; a
513// re-rendered one gets a new key and decodes fresh.
514export function keyOf(text: string): string {
515 let hash = 5381
516 for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
517 return hash.toString(16).padStart(8, '0') + text.length.toString(16)
518}
519export async function identityOf($: EngineInterface, raw: string): Promise<string | undefined> {
520 const st = await $.fs.stat(expand(raw, state.cwd, state.home), { resolve: true }).catch(() => null)
521 return st ? keyOf(`${st.realPath ?? raw}|${st.size}|${st.mtimeMs}`) : undefined
522}
523// What a folder's meta.json holds, unchecked: the caller says what it expects.
524// (No type argument at the call: the directory review reads only fn($, ...).)
525async function readMeta($: EngineInterface, dir: string): Promise<unknown> {
526 const r = await $.process.run(['cat', `${dir}/meta.json`]).catch(() => null)
527 if (!r || r.exitCode !== 0) return null
528 try { return JSON.parse(r.stdout) as unknown } catch { return null }
529}
530function writeMeta($: EngineInterface, dir: string, meta: unknown) {
531 return $.process.run(['sh', '-c', 'printf "%s" "$1" > "$0/meta.json"', dir, JSON.stringify(meta)]).catch(() => undefined)
532}
533
534export async function prepareVideo($: EngineInterface, real: string, title: string, key?: string, cutAt?: number): Promise<Shown> {
535 if (!state.hasVideoTools) {
536 state.hasVideoTools = (await Promise.all([exists($, state.ffmpeg), exists($, state.ffprobe)])).every(Boolean)
537 if (!state.hasVideoTools) throw new Error(noFfmpeg(state.isMac))
538 }
539 const facts = await $.process.run([state.ffprobe, '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', 'default=nw=1', real])
540 const field = (name: string) => Number(new RegExp(`^${name}=([0-9.]+)`, 'm').exec(facts.stdout)?.[1] ?? Number.NaN)
541 const cut = cutAt ?? frameWidth(field('width'), field('height'), state.termSize, state.cell)
542 const own = field('width') > 0 && field('height') > 0 ? { sourceWidth: field('width'), sourceHeight: field('height') } : {}
543 // A clip is cut once per width: a bigger window cuts it again, bigger.
544 const folder = key && `v-${keyOf(`${key}|${cut}${state.sendBytes ? '|bytes' : ''}`)}`
545 // The same file shown before: its folder, frames and size are already there.
546 if (folder) {
547 const dir = `${state.TMP}/${folder}`
548 const meta = (await readMeta($, dir)) as { frames: Omit<Frames, 'dir'>; width: number; height: number } | null
549 if (meta) {
550 const frames = { ...meta.frames, dir }
551 await $.process.run(['touch', dir]).catch(() => undefined)
552 await ensureChunk($, frames, real, 0)
553 await prefill($, frames, real)
554 return { kind: 'video', path: frameFile(frames, 0), source: real, width: meta.width, height: meta.height, ...own, title, generation: ++state.generation, frames }
555 }
556 }
557 // Some recordings (MediaRecorder webm) carry no duration: sample those at the
558 // top rate. The clip's length is then what the frames cover.
559 const probed = field('duration')
560 const fps = sampleRate(Number.isFinite(probed) && probed > 0 ? probed : 1)
561 state.generation += 1
562 const dir = folder ? `${state.TMP}/${folder}` : `${state.TMP}/v${state.generation}-${Math.floor(await $.clock.now())}`
563 await $.process.run(['mkdir', '-p', dir])
564 const lead = await whiteLead($, real, probed)
565 const known = Number.isFinite(probed) && probed > 0
566 // A clip of known length is decoded as it plays, a chunk at a time (see
567 // ensureChunk); one without (MediaRecorder webm) is cut whole, as before.
568 const total = known ? Math.max(1, Math.min(MAX_STREAMED, Math.floor((probed - lead) * fps))) : 0
569 const made = await $.process.run([
570 // The palette waits for the end of its input: only the first chunk is read.
571 state.ffmpeg, '-v', 'error', ...(lead ? ['-ss', String(lead)] : []), ...(known ? ['-t', chunkSeconds(Math.min(CHUNK, total), fps)] : []), '-i', real,
572 '-vf', `fps=${fps},${scaleFor(cut)}`,
573 '-frames:v', String(known ? Math.min(CHUNK, total) : MAX_FRAMES), '-compression_level', '1', `${dir}/f%05d.png`,
574 ])
575 if (made.exitCode !== 0) throw new Error(`ffmpeg could not read ${real}: ${made.stderr.trim().slice(0, 200)}`)
576 const pngs = (await $.fs.list(dir)).filter(f => f.name.endsWith('.png')).length
577 if (!pngs) throw new Error(`no frames in ${real}`)
578 const count = known ? Math.max(pngs, total) : pngs
579 const frames = { dir, count, fps, duration: count / fps, cut, ...(lead ? { lead } : {}) }
580 const poster = frameFile(frames, 0)
581
582 const size = await pngSize($, poster)
583 if (folder) await writeMeta($, dir, { frames: { count, fps, duration: frames.duration, cut, ...(lead ? { lead } : {}) }, ...size })
584 void trimFrames($)
585 if (known) await prefill($, frames, real)
586 return { kind: 'video', path: poster, source: real, ...size, ...own, title, generation: state.generation, frames }
587}
588
589// Frames are decoded from the clip as it plays, 24 at a time: the chunk on
590// screen and the ones a 5 s skip either way reaches stay on disk, the rest
591// go. A frame asked for again further away (a long seek, a window scrolled
592// back to) is decoded again. The poster, frame 1, always stays.
593const CHUNK = 24
594// Chunks with time to spare (two or more ahead of the playhead) are cut at
595// a lower priority. On macOS that's the utility class: measured on an M1, a
596// 2 s chunk cut in 1.2 s with the machine free, the same as normal, and in
597// 7 s with every core busy, giving way. nice 10 didn't give way at all on
598// macOS, and the background class took 4.7 s even on a free machine. Linux
599// uses nice, which does give way there. The chunk needed next always runs at
600// normal priority, so playback keeps moving however busy the machine is.
601const GENTLY = 'if command -v taskpolicy >/dev/null 2>&1; then exec taskpolicy -c utility "$@"; fi; if command -v nice >/dev/null 2>&1; then exec nice -n 10 "$@"; fi; exec "$@"'
602const gently = (argv: string[]) => ['sh', '-c', GENTLY, 'sh', ...argv]
603// And on one thread: ffmpeg's default spreads one cut over several cores and
604// buffers for each. Measured on an M1 for 1 s of video at 2400 px: default
605// 1.3 s of CPU and 368 MB, one thread 0.85 s and 213 MB, still faster than
606// playback. The first chunk, cut before the video shows, keeps the default
607// so it appears quickly.
608const ONE_THREAD = ['-threads', '1', '-filter_threads', '1']
609const decoding = new Map<string, Promise<void>>()
610export function exists($: EngineInterface, file: string) {
611 return $.fs.stat(file).then(() => true, () => false)
612}
613// Gone only when the disk says so; no answer at all (no fs here) is not gone.
614export function isGone($: EngineInterface, file: string) {
615 return $.fs.stat(file).then(() => false, (e: unknown) => /ENOENT|not found|no such file/i.test(String((e as Error)?.message ?? e)))
616}
617
618// Chunks whose frames were cut: playback waits on any other. Only a hint:
619// cutting still checks the files, so a chunk the cache cleaner took comes back.
620const cutChunks = new Set<string>()
621const chunkKey = (f: Frames, i: number) => `${f.dir}#${Math.floor(Math.max(0, Math.min(i, f.count - 1)) / CHUNK)}`
622export const isFrameCut = (media: Shown, i: number) => !media.frames || cutChunks.has(chunkKey(media.frames, i))
623
624// Over ssh every frame crosses as bytes: 256 colours, one palette per chunk,
625// no dithering, halve them. Frames on this machine go by path in full colour.
626const PALETTE = ',split[a][b];[a]palettegen[p];[b][p]paletteuse=dither=none'
627// How much of the clip a chunk reads, a frame's slack included.
628export const chunkSeconds = (frames: number, fps: number) => String(+((frames + 1) / fps).toFixed(3))
629const scaleFor = (cut?: number) => (cut ? `scale=${cut}:-2` : "scale='min(1600,iw)':-2") + (state.sendBytes ? PALETTE : '')
630
631function ensureChunk($: EngineInterface, f: Frames, source: string | undefined, i: number, isUrgent = true): Promise<void> {
632 const c = Math.floor(Math.max(0, Math.min(i, f.count - 1)) / CHUNK)
633 const key = `${f.dir}#${c}`
634 const running = decoding.get(key)
635 if (running) return running
636 const job = (async () => {
637 const first = c * CHUNK
638 // The frame asked for, not the chunk's first: the poster outlives its chunk.
639 if (await exists($, frameFile(f, Math.max(0, Math.min(i, f.count - 1))))) return
640 if (!source) return
641 const cutAt = Date.now()
642 await $.process.run(['mkdir', '-p', f.dir])
643 const cutChunk = [
644 state.ffmpeg, '-v', 'error', ...ONE_THREAD, '-ss', String(+((f.lead ?? 0) + first / f.fps).toFixed(3)), '-t', chunkSeconds(Math.min(CHUNK, f.count - first), f.fps), '-i', source,
645 '-vf', `fps=${f.fps},${scaleFor(f.cut)}`, '-frames:v', String(Math.min(CHUNK, f.count - first)),
646 '-threads', '1', '-start_number', String(first + 1), '-compression_level', '1', `${f.dir}/f%05d.png`,
647 ]
648 await $.process.run(isUrgent ? cutChunk : gently(cutChunk))
649 debug(`cut chunk=${c} ms=${Date.now() - cutAt}`)
650 await prune($, f.dir, c, f.fps)
651 })().then(() => { cutChunks.add(key) }, () => undefined).finally(() => decoding.delete(key))
652 decoding.set(key, job)
653 return job
654}
655
656// Where each player is in each frames folder, as a chunk number.
657const watching = new Map<string, Map<string, number>>()
658function watch(dir: string, site: string, i: number) {
659 let at = watching.get(dir)
660 if (!at) watching.set(dir, (at = new Map()))
661 at.set(site, Math.floor(i / CHUNK))
662}
663
664// Chunks 5 s behind the playhead stay on disk and 5 s ahead are cut in
665// advance, so « 5s and 5s » land on frames that are ready, and playback has
666// room to spare when cutting runs slower than the clip.
667const BEHIND_SECONDS = 5
668const AHEAD_SECONDS = 5
669const chunksFor = (seconds: number, fps: number) => Math.max(1, Math.ceil((seconds * fps) / CHUNK))
670export const chunksBehind = (fps: number) => chunksFor(BEHIND_SECONDS, fps)
671export const chunksAhead = (fps: number) => chunksFor(AHEAD_SECONDS, fps)
672
673// Keeps, in a frames folder, the chunks a skip back or forward reaches around
674// the chunk just made and around every player on that folder, and the poster.
675async function prune($: EngineInterface, dir: string, c: number, fps: number) {
676 const keep = [c, ...(watching.get(dir)?.values() ?? [])]
677 const behind = chunksBehind(fps)
678 const ahead = chunksAhead(fps) + 1
679 const files = await $.fs.list(dir).catch(() => [])
680 const gone = files.map(f => f.name).filter(name => {
681 const m = /^f(\d+)\.png$/.exec(name)
682 if (!m) return false
683 const i = Number(m[1]) - 1
684 return i > 0 && !keep.some(k => i >= (k - behind) * CHUNK && i < (k + ahead + 1) * CHUNK)
685 })
686 if (gone.length) {
687 for (const k of [...cutChunks]) if (k.startsWith(`${dir}#`) && !keep.some(c2 => { const j = Number(k.slice(dir.length + 1)); return j >= c2 - behind && j <= c2 + ahead })) cutChunks.delete(k)
688 await $.process.run(['rm', '-f', ...gone.map(n => `${dir}/${n}`)]).catch(() => undefined)
689 }
690}
691
692// The frame a side shows, decoded if it has to be; then the chunks a 5s »
693// reaches are cut ahead, one at a time so a busy machine isn't swamped.
694export async function frameReady($: EngineInterface, media: Shown, i: number, site?: string): Promise<void> {
695 const f = media.frames
696 if (!f) return
697 if (site) watch(f.dir, site, i)
698 await ensureChunk($, f, media.source, i)
699 void cutAhead($, f, media.source, i)
700}
701
702// Two cuts at once, nearest first: PNG encoding runs one frame at a time
703// in each ffmpeg, so a second process gets ~20-25% more done on an M1; a
704// third and fourth add little and crowd a busy machine.
705const CUT_WORKERS = 2
706const cuttingAhead = new Set<string>()
707async function cutAhead($: EngineInterface, f: Frames, source: string | undefined, i: number) {
708 if (cuttingAhead.has(f.dir)) return
709 cuttingAhead.add(f.dir)
710 try {
711 // The chunk after this one is needed within 2 s; the rest have time.
712 const todo: { j: number; isUrgent: boolean }[] = []
713 for (let d = 1; d <= chunksAhead(f.fps); d++) {
714 const j = i + d * CHUNK
715 if (j >= f.count) break
716 if (!cutChunks.has(chunkKey(f, j))) todo.push({ j, isUrgent: d === 1 })
717 }
718 let taken = 0
719 const worker = async () => {
720 while (taken < todo.length) {
721 const { j, isUrgent } = todo[taken++]!
722 await ensureChunk($, f, source, j, isUrgent)
723 }
724 }
725 await Promise.all(Array.from({ length: CUT_WORKERS }, worker))
726 } finally {
727 cuttingAhead.delete(f.dir)
728 }
729}
730
731// Before a video is shown, the first 5 s ahead are cut too, so playback
732// starts with room to spare. Never more than PREFILL_MS of waiting.
733const PREFILL_MS = 4000
734async function prefill($: EngineInterface, f: Frames, source: string | undefined) {
735 await Promise.race([cutAhead($, f, source, 0), $.clock.sleep(PREFILL_MS)]).catch(() => undefined)
736}
737
738// Moments both runs share (a check by the same name), as [updated, original]
739// seconds, kept only while both clocks move forward.
740export function anchors(updated: Check[] = [], original: Check[] = []): [number, number][] {
741 const at = new Map(original.filter(c => c.at !== undefined).map(c => [c.name, c.at!]))
742 const pairs = updated
743 .filter(c => c.at !== undefined && at.has(c.name))
744 .map(c => [c.at!, at.get(c.name)!] as [number, number])
745 .sort((a, b) => a[0] - b[0])
746 const kept: [number, number][] = []
747 for (const p of pairs) {
748 const last = kept.at(-1) ?? [0, 0]
749 if (p[0] > last[0] + 0.05 && p[1] > last[1] + 0.05) kept.push(p)
750 }
751
752 return kept
753}
754
755// The original clip's moment that matches a moment of the updated clip.
756export function mapTime(points: [number, number][], t: number, endA: number, endB: number): number {
757 const all: [number, number][] = [[0, 0], ...points, [endA, endB]]
758 for (let i = 1; i < all.length; i++) {
759 const [a0, b0] = all[i - 1]!
760 const [a1, b1] = all[i]!
761 if (t <= a1 || i === all.length - 1) {
762 const k = a1 > a0 ? (b1 - b0) / (a1 - a0) : 1
763 return Math.max(0, Math.min(endB, b0 + (t - a0) * k))
764 }
765 }
766
767 return t
768}
769
770// Maps a frame of one clip to the frame at the same moment in another.
771export function sameMoment(from: Frames | undefined, to: Frames | undefined, frame: number): number {
772 if (!from || !to) return 0
773
774 return Math.max(0, Math.min(to.count - 1, Math.round((frame / from.fps) * to.fps)))
775}
776
777// The same moment on the other clip: updated → original seconds, and back.
778export const toOriginal = (shown: Shown, t: number) =>
779 shown.frames && shown.compare?.frames
780 ? mapTime(shown.sync ?? anchors(shown.checks, shown.compare.checks), t, shown.frames.duration, shown.compare.frames.duration)
781 : t
782export const toUpdated = (shown: Shown, t: number) =>
783 shown.frames && shown.compare?.frames
784 ? mapTime((shown.sync ?? anchors(shown.checks, shown.compare.checks)).map(([a, b]) => [b, a] as [number, number]), t, shown.compare.frames.duration, shown.frames.duration)
785 : t
786
787// The compared side's frame at the moment the shown side is at.
788export const compareFrame = (shown: Shown, frame: number) => {
789 const a = shown.frames, b = shown.compare?.frames
790 if (!a || !b) return 0
791 const t = mapTime(shown.sync ?? anchors(shown.checks, shown.compare?.checks), frame / a.fps, a.duration, b.duration)
792 return Math.max(0, Math.min(b.count - 1, Math.round(t * b.fps)))
793}
794
795// Every `stride`-th frame as a 32×18 grey thumbnail, for matching the two
796// runs by eye. Small and sparse on purpose: matching costs frames × frames.
797async function thumbs($: EngineInterface, media: Shown, stride: number): Promise<number[][]> {
798 const f = media.frames!
799 const r = await $.process.run([
800 'sh', '-c',
801 `"$0" -v error ${f.lead ? `-ss ${f.lead} ` : ''}-i "$1" -vf "fps=${f.fps},select=not(mod(n\\,${stride})),scale=32:18,format=gray" -fps_mode passthrough -frames:v ${Math.ceil(f.count / stride)} -f rawvideo - | od -An -tu1 -v`,
802 state.ffmpeg, media.source ?? '',
803 ])
804 const px = r.stdout.trim().split(/\s+/).map(Number)
805 const out: number[][] = []
806 for (let i = 0; i + 576 <= px.length; i += 576) out.push(px.slice(i, i + 576))
807 return out
808}
809
810// How many frames apart to sample so neither run has more than 240.
811export const strideFor = (a: number, b: number) => Math.max(1, Math.ceil(Math.max(a, b) / 240))
812
813// A match made on sampled frames, spread back over every updated frame.
814export function spreadMatch(sampled: number[], stride: number, count: number, last: number): number[] {
815 return Array.from({ length: count }, (_, i) => {
816 const k = Math.min(sampled.length - 1, Math.floor(i / stride))
817 return Math.min(last, (sampled[k] ?? 0) * stride + (i % stride))
818 })
819}
820
821// What moved since the previous frame: the cursor, a fade, a scroll. The two
822// runs move the same way even where the designs differ.
823const motion = (f: number[][]) => f.map((px, i) => (i ? px.map((v, k) => v - f[i - 1]![k]!) : px.map(() => 0)))
824
825// The cheapest way to walk both runs in order, frame by frame (dynamic time
826// warping), matching how frames look and how they move: [updated, original]
827// frame pairs. Holding one side still costs a little, so it only holds where
828// the runs really differ. Within `band` samples of the same moment only, and
829// straying from it costs `drift`: two runs of the same steps keep time, so a
830// screen that looks different in one (an error the other never shows) can't
831// pull one side ahead of the other.
832export function align(a: number[][], b: number[][], hold = 0.01, band = Number.POSITIVE_INFINITY, drift = 0): [number, number][] {
833 const n = a.length, m = b.length
834 if (!n || !m) return []
835 const slope = n > 1 ? (m - 1) / (n - 1) : 0
836 // Typed rows: the inner loop runs n × m × pixels times.
837 const ta = a.map(x => Int16Array.from(x)), tb = b.map(x => Int16Array.from(x))
838 const ma = motion(a).map(x => Int16Array.from(x)), mb = motion(b).map(x => Int16Array.from(x))
839 const diff = (i: number, j: number) => {
840 let d = 0, e = 0
841 const pa = ta[i]!, pb = tb[j]!, qa = ma[i]!, qb = mb[j]!
842 for (let k = 0; k < pa.length; k++) {
843 const x = pa[k]! - pb[k]!, y = qa[k]! - qb[k]!
844 d += x < 0 ? -x : x
845 e += y < 0 ? -y : y
846 }
847 return (d + 4 * e) / (pa.length * 255)
848 }
849 const cost = new Float64Array(n * m)
850 const step = new Uint8Array(n * m) // 0 both, 1 updated only, 2 original only
851 for (let i = 0; i < n; i++) {
852 for (let j = 0; j < m; j++) {
853 const off = Math.abs(j - i * slope)
854 if (off > band && !(i === n - 1 && j === m - 1) && (i || j)) { cost[i * m + j] = Number.POSITIVE_INFINITY; step[i * m + j] = 0; continue }
855 const d = diff(i, j) + drift * off
856 if (!i && !j) { cost[0] = d; continue }
857 const both = i && j ? cost[(i - 1) * m + j - 1]! : Number.POSITIVE_INFINITY
858 const up = i ? cost[(i - 1) * m + j]! + hold : Number.POSITIVE_INFINITY
859 const left = j ? cost[i * m + j - 1]! + hold : Number.POSITIVE_INFINITY
860 const best = Math.min(both, up, left)
861 cost[i * m + j] = best + d
862 step[i * m + j] = best === both ? 0 : best === up ? 1 : 2
863 }
864 }
865 const path: [number, number][] = []
866 for (let i = n - 1, j = m - 1; ; ) {
867 path.push([i, j])
868 if (!i && !j) break
869 const s = step[i * m + j]
870 if (s === 0) { i--; j-- } else if (s === 1) i--
871 else j--
872 }
873 return path.reverse()
874}
875
876// For each updated frame, the original frame shown beside it.
877export function matchOf(path: [number, number][], count: number): number[] {
878 const out = new Array<number>(count).fill(0)
879 for (const [i, j] of path) if (i < count) out[i] = j
880 for (let i = 1; i < count; i++) out[i] = Math.max(out[i]!, out[i - 1]!)
881 return out
882}
883
884// The path cut to the few turns that matter, in seconds, to keep it small.
885export function turns(match: number[], fpsA: number, fpsB: number): [number, number][] {
886 const points: [number, number][] = []
887 for (let i = 1; i < match.length - 1; i++) {
888 const before = match[i]! - match[i - 1]!, after = match[i + 1]! - match[i]!
889 if ((before === 0) !== (after === 0) || Math.abs(before - after) > 1) points.push([+(i / fpsA).toFixed(2), +(match[i]! / fpsB).toFixed(2)])
890 }
891 return points.length > 40 ? points.filter((_, k) => k % Math.ceil(points.length / 40) === 0) : points
892}
893
894// The largest still kept whole.
895export const MAX_STILL = 3200
896
897// Pixels between Original and Updated in the merged clip.
898export const PAIR_GAP = 40
899// How tall the merged clip is cut: the shorter side's height, 720 px at most.
900// The terminal never draws a picture larger than it is, so 360 px kept the
901// side by side view small in any window.
902export const pairHeightOf = (updated: { height: number }, original: { height: number }) =>
903 Math.max(2, Math.floor(Math.min(720, updated.height || 720, original.height || 720) / 2) * 2)
904
905// Merges the original and updated recordings into one clip, side by side.
906// The original's frames are matched to the updated clip's by how they look,
907// so every step happens at the same moment on both sides.
908export async function preparePair($: EngineInterface, original: Shown, updated: Shown) {
909 const f = updated.frames!, g = original.frames!
910 state.generation += 1
911 // p2: pairs made before the time-first matching are never reused.
912 const dir = `${state.TMP}/p2-${keyOf(`${f.dir}|${g.dir}|${pairHeightOf(updated, original)}`)}`
913 type PairMeta = { pair: Omit<Frames, 'dir'>; pairWidth: number; pairHeight: number; sync: [number, number][] }
914 const known = (await readMeta($, dir)) as PairMeta | null
915 if (known) {
916 const pair = { ...known.pair, dir }
917 await $.process.run(['touch', dir]).catch(() => undefined)
918 await pairChunk($, pair, updated, original, i => compareFrame({ ...updated, compare: original, sync: known.sync }, i), 0)
919 return { pair, pairWidth: known.pairWidth, pairHeight: known.pairHeight, sync: known.sync }
920 }
921 // Two runs of the same steps take about as long: then the same moment on
922 // both sides is the same second (moved to the checks' own moments when
923 // they're given). Only runs that differ a lot are matched by how they look.
924 const byTime = () => Array.from({ length: f.count }, (_, i) => Math.min(g.count - 1, Math.round(mapTime(anchors(updated.checks, original.checks), i / f.fps, f.duration, g.duration) * g.fps)))
925 const isSamePace = Math.abs(f.duration - g.duration) <= 0.15 * Math.max(f.duration, g.duration)
926 const stride = strideFor(f.count, g.count)
927 const [ta, tb] = isSamePace ? [[], []] : await Promise.all([thumbs($, updated, stride), thumbs($, original, stride)])
928 const match = ta.length && tb.length
929 ? spreadMatch(matchOf(align(ta, tb, 0.01, Math.max(2, Math.round((0.6 * f.fps) / stride)), 0.02), ta.length), stride, f.count, g.count - 1)
930 : byTime()
931 await $.process.run(['mkdir', '-p', dir])
932 const pair = { dir, count: f.count, fps: f.fps, duration: f.duration }
933 await pairChunk($, pair, updated, original, i => match[i] ?? 0, 0)
934 if (!(await exists($, frameFile(pair, 0)))) return null
935 const size = await pngSize($, frameFile(pair, 0))
936 const made = { pair, pairWidth: size.width, pairHeight: size.height, sync: turns(match, f.fps, g.fps) }
937 await writeMeta($, dir, { ...made, pair: { count: pair.count, fps: pair.fps, duration: pair.duration } })
938
939 return made
940}
941
942// One chunk of the merged clip, Original left and Updated right, made from
943// the two sides' frames for that stretch (decoded first if they have to be).
944function pairChunk($: EngineInterface, pair: Frames, updated: Shown, original: Shown, jOf: (i: number) => number, i: number): Promise<void> {
945 const c = Math.floor(Math.max(0, Math.min(i, pair.count - 1)) / CHUNK)
946 const key = `${pair.dir}#${c}`
947 const running = decoding.get(key)
948 if (running) return running
949 const f = updated.frames!, g = original.frames!
950 const job = (async () => {
951 const first = c * CHUNK, n = Math.min(CHUNK, pair.count - first)
952 if (await exists($, frameFile(pair, Math.max(0, Math.min(i, pair.count - 1))))) return
953 const js = Array.from({ length: n }, (_, k) => Math.max(0, Math.min(g.count - 1, jOf(first + k))))
954 await ensureChunk($, f, updated.source, first)
955 for (const jc of new Set(js.map(j => Math.floor(j / CHUNK)))) await ensureChunk($, g, original.source, jc * CHUNK)
956 const tmp = `${pair.dir}/c${c}`
957 await $.process.run(['mkdir', '-p', `${tmp}/o`, `${tmp}/u`])
958 const links = js.map((j, k) => `ln -sf "${frameFile(g, j)}" "$0/o/f${String(k + 1).padStart(5, '0')}.png"; ln -sf "${frameFile(f, first + k)}" "$0/u/f${String(k + 1).padStart(5, '0')}.png"`).join('\n')
959 await $.process.run(['sh', '-c', links, tmp])
960 // Needed on screen now: normal priority.
961 await $.process.run([
962 state.ffmpeg, '-v', 'error', ...ONE_THREAD, '-framerate', String(f.fps), '-i', `${tmp}/o/f%05d.png`, '-framerate', String(f.fps), '-i', `${tmp}/u/f%05d.png`,
963 '-filter_complex', `[0:v]scale=-2:${pairHeightOf(updated, original)},pad=iw+${PAIR_GAP}:ih:0:0:color=${SIDEBAR.replace('#', '0x')}[a];[1:v]scale=-2:${pairHeightOf(updated, original)}[b];[a][b]hstack=inputs=2`,
964 '-frames:v', String(n), '-threads', '1', '-start_number', String(first + 1), '-compression_level', '1', `${pair.dir}/f%05d.png`,
965 ])
966 await $.process.run(['rm', '-rf', tmp])
967 await prune($, pair.dir, c, pair.fps)
968 })().catch(() => undefined).finally(() => decoding.delete(key))
969 decoding.set(key, job)
970 return job
971}
972
973// The merged frame on screen, made if it has to be, the next chunk ahead.
974export async function pairReady($: EngineInterface, shown: Shown, i: number, site?: string): Promise<void> {
975 const pair = shown.pair
976 if (!pair || !shown.compare) return
977 if (site) watch(pair.dir, site, i)
978 const jOf = (k: number) => compareFrame(shown, k)
979 await pairChunk($, pair, shown, shown.compare, jOf, i)
980 if (i % CHUNK >= CHUNK / 2 && i + CHUNK < pair.count) void pairChunk($, pair, shown, shown.compare, jOf, i + CHUNK)
981}
982
983// Resolves a path to something the terminal can draw: a PNG (other image
984// formats converted with macOS `sips`), or a video's frames.
985export async function prepare($: EngineInterface, raw: string, title?: string, key?: string): Promise<Shown> {
986 const path = expand(raw, state.cwd, state.home)
987 const stat = await $.fs.stat(path, { resolve: true })
988 const real = stat.realPath ?? path
989 const name = title ?? real.split('/').pop()!
990 if (VIDEO.test(real)) return prepareVideo($, real, name, key ?? (await identityOf($, real)))
991 let png = real
992 if (!/\.png$/i.test(real)) {
993 state.generation += 1
994 png = `${state.TMP}/${real.split('/').pop()!.replace(/\.[^.]+$/, '')}-${state.generation}.png`
995 await $.process.run(['mkdir', '-p', state.TMP])
996 if (!state.isMac && !(await exists($, state.ffmpeg))) throw new Error(noFfmpeg(false))
997 const conv = state.isMac
998 ? await $.process.run(['sips', '-s', 'format', 'png', real, '--out', png])
999 : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', real, '-frames:v', '1', png])
1000 if (conv.exitCode !== 0) throw new Error(`could not convert ${real}: ${conv.stderr.trim()}`)
1001 }
1002 // The terminal reads the file itself, so only a picture past what any
1003 // screen shows is shrunk.
1004 const dims = await pngSize($, png)
1005 if (Math.max(dims.width, dims.height) > MAX_STILL) {
1006 state.generation += 1
1007 const small = `${state.TMP}/${png.split('/').pop()!.replace(/\.png$/i, '')}-${state.generation}-small.png`
1008 await $.process.run(['mkdir', '-p', state.TMP])
1009 const shrunk = state.isMac
1010 ? await $.process.run(['sips', '-Z', String(MAX_STILL), png, '--out', small])
1011 : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', png, '-vf', `scale='if(gt(iw,ih),min(${MAX_STILL},iw),-2)':'if(gt(iw,ih),-2,min(${MAX_STILL},ih))'`, small]).catch(() => ({ exitCode: 1 }))
1012 if (state.isMac || shrunk.exitCode === 0) png = small
1013 }
1014 // A picture narrower than the window shows it is drawn that wide, as a
1015 // video's frames are, so a picture and a video of one shape are one size.
1016 // Never past twice its own width: a small icon stays small.
1017 const now = await pngSize($, png)
1018 const wide = Math.min(frameWidth(now.width, now.height, state.termSize, state.cell), now.width * 2)
1019 if (wide > now.width) {
1020 state.generation += 1
1021 const big = `${state.TMP}/${png.split('/').pop()!.replace(/\.png$/i, '')}-${state.generation}-w${wide}.png`
1022 await $.process.run(['mkdir', '-p', state.TMP])
1023 const grown = state.isMac
1024 ? await $.process.run(['sips', '--resampleWidth', String(wide), png, '--out', big]).catch(() => ({ exitCode: 1 }))
1025 : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', png, '-vf', `scale=${wide}:-2:flags=lanczos`, big]).catch(() => ({ exitCode: 1 }))
1026 if (grown.exitCode === 0) png = big
1027 }
1028 state.generation += 1
1029
1030 return { kind: 'image', path: png, source: real, ...(await pngSize($, png)), sourceWidth: dims.width, sourceHeight: dims.height, title: name, generation: state.generation }
1031}
1032
1033// ── tour.ts ───────────────────────────────────────────────────────────────
1034// The tour's stops, and how a press in the player ticks the one it asked for.
1035
1036
1037// The settings pane's and the tour's redraws. Declared in each file that
1038// reads or updates it: the engine only reads an atom declared in the same file.
1039const welcome = atom({ plugin: 'show-your-work', key: 'welcome' } as const, 0)
1040
1041// Each stop points at one real control (spot) with one short line (tag), and
1042// is done when the person uses it (goal: the act() name its button fires).
1043type Spot = 'play' | 'changes' | 'tabs' | 'history'
1044export type Stop = { tag: string; spot?: Spot; goal?: string; media?: 'splash' | 'recording' | 'compare' | 'history' }
1045export const STOPS: Stop[] = [
1046 { tag: 'Press Play to watch it', spot: 'play', goal: 'play', media: 'splash' },
1047 { tag: 'Click a change to jump to that moment', spot: 'changes', goal: 'change_jump', media: 'recording' },
1048 { tag: 'Press Original to see it before the change', spot: 'tabs', goal: 'original', media: 'compare' },
1049 { tag: 'Open History, then pick a version', spot: 'history', goal: 'version_pick', media: 'history' },
1050 { tag: 'Now ask Claude to show you what it builds' },
1051]
1052
1053// A button press, heard: when it is what the stop asked for, it ticks; the
1054// person moves on with Next when they're ready.
1055export function tourHeard($: EngineInterface, action: string) {
1056 const s = STOPS[state.tourStop]
1057 if (!state.tourSite || state.isTourDone || state.isStopDone || !s?.goal || s.goal !== action) return
1058 state.isStopDone = true
1059 // Sent once per step, numbered from 0 like tour_next_N and tour_skip_N, so a
1060 // funnel can tell people who did the step from people who only pressed Next.
1061 act(`tour_step_done_${state.tourStop}`)
1062 void update($, welcome, n => n + 1)
1063}
1064
1065// ── player.tsx ────────────────────────────────────────────────────────────
1066// The player: playback (frames, swaps, play and seek) and the components that
1067// draw it in the chat.
1068
1069
1070
1071// Playback per drawn site (a transcript row's tool_use_id, or the pane).
1072type Player = {
1073 frame: number
1074 timer?: Timer
1075 isCompare?: boolean
1076 isSplit?: boolean
1077 // which earlier version the compared side is, when there are several
1078 step?: number
1079 // the sidebar lists the versions instead of what changed
1080 isHistory?: boolean
1081 // scrolled out of view since it was last drawn
1082 wasAway?: boolean
1083 // back in view: send the frame on screen again after drawing
1084 resend?: boolean
1085 // state.drawnBytes when this player's picture was last sent
1086 drawnAt?: number
1087 hasAutoplayed?: boolean
1088 // how many bar cells the scrub row last drew, so playback redraws only on a change
1089 segments?: number
1090 // which page of key changes the sidebar shows
1091 changesPage?: number
1092 // where the updated video was, kept while a still original is shown
1093 updatedFrame?: number
1094 // the picture's size in cells as last drawn, for repaints
1095 box?: { columns: number; rows: number }
1096}
1097export const players = new Map<string, Player>()
1098// Calls made since this load: only these autoplay, not every window a reload redraws.
1099export const freshCalls = new Set<string>()
1100// Each window's own repaint count, beside state.version for all of them.
1101export const versions = new Map<string, number>()
1102
1103// A change to one window bumps only its own count, so the others hand back
1104// their kept drawing when the repaint reaches them.
1105export function redraw($: EngineInterface, site?: string) {
1106 if (site) versions.set(site, (versions.get(site) ?? 0) + 1)
1107 else state.version += 1
1108 $.ui.invalidate('ui.render')
1109}
1110export const player = (site: string): Player => {
1111 let one = players.get(site)
1112 if (!one) players.set(site, (one = { frame: 0 }))
1113
1114 return one
1115}
1116
1117// Opens a file, folder or URL with the desktop's default app.
1118//
1119// On Linux, xdg-open on a desktop it doesn't recognise (niri, sway…) runs the
1120// app in the foreground until it quits, and $.process.run kills whatever is
1121// still running at its timeout (30 s by default). So the script below starts
1122// xdg-open in a session of its own, then watches it for up to a second:
1123// - xdg-open exits within the second (GNOME and KDE hand the file off and
1124// return; a missing app fails fast): its exit code and error text come back.
1125// - still running after the second: the app is open in the foreground, so
1126// the script returns 0 and leaves it running, outside what the timeout kills.
1127// The exit code goes through a file, because a background job that has exited
1128// still answers `kill -0` until it is reaped. The helper's own input and output
1129// go to /dev/null too: $.process.run waits until every holder of its pipes exits.
1130export const OPEN_SH = [
1131 'command -v "$1" >/dev/null || exit 127',
1132 'st="${TMPDIR:-/tmp}/syw-open-$$"',
1133 'run=; command -v setsid >/dev/null && run=setsid',
1134 '$run sh -c \'"$@" </dev/null >/dev/null 2>"$0.err"; echo $? > "$0"; sleep 2; rm -f "$0" "$0.err"\' "$st" "$@" </dev/null >/dev/null 2>&1 &',
1135 'i=0',
1136 'while [ $i -lt 10 ]; do',
1137 ' if [ -s "$st" ]; then code=$(cat "$st"); cat "$st.err" >&2; rm -f "$st" "$st.err"; exit "$code"; fi',
1138 ' sleep 0.1 2>/dev/null || sleep 1; i=$((i + 1))',
1139 'done',
1140 'exit 0',
1141].join('\n')
1142const openArgv = (target: string) => (state.isMac ? ['open', target] : ['sh', '-c', OPEN_SH, 'sh', 'xdg-open', target])
1143
1144// What went wrong opening something, in words: from xdg-open's exit codes
1145// (2 no such file, 3 no app or tool for it, 4 the app failed) and from macOS
1146// open's error text. `what` names the kind of thing, as in "no app opens videos".
1147export function openFailure(r: { exitCode: number; stderr: string }, what: string, path: string) {
1148 const said = r.stderr.trim().split('\n')[0]?.trim().slice(0, 100) ?? ''
1149 if (r.exitCode === 127) return "Couldn't open it: xdg-open is missing. Install xdg-utils and try again."
1150 if (r.exitCode === 2 || /does not exist|no such file/i.test(said)) return `Couldn't open it: nothing is at ${path} any more.`
1151 if (r.exitCode === 3 || /no application knows|no application|no method available|no default/i.test(said)) return `Couldn't open it: no app on this computer is set to open ${what}.`
1152 if (r.exitCode === 4) return said ? `Couldn't open it: ${said}` : `Couldn't open it: the app for ${what} failed to start.`
1153 return said ? `Couldn't open it: ${said}` : `Couldn't open it (error ${r.exitCode}).`
1154}
1155
1156// The narrowest the scrub and button rows get, in columns: room for the play
1157// button, both times, a usable bar and the action buttons on one line.
1158const CONTROLS_MIN = 64
1159
1160// A player whose box needs more pixels than its picture has (a bigger
1161// window, a pane, no report beside it) gets a sharper copy, made once in the
1162// background from the original file and swapped into the same object, so a
1163// playing video carries on. Stills stop at twice their own width (an icon
1164// stays small) and MAX_STILL; videos at PX_MAX.
1165const sharpKey = (m: Shown) => `${m.kind}|${m.source ?? m.path}`
1166const sharpening = new Map<string, number>()
1167const sharpest = new Map<string, Pick<Shown, 'path' | 'width' | 'height' | 'generation' | 'frames'>>()
1168function useSharpest(m: Shown) {
1169 const better = sharpest.get(sharpKey(m))
1170 if (better && better.width > m.width) Object.assign(m, better)
1171}
1172function sharpen($: EngineInterface, m: Shown, need: number) {
1173 const source = m.source
1174 const limit = m.frames ? PX_MAX : Math.min(MAX_STILL, (m.sourceWidth ?? m.width) * 2)
1175 const want = Math.min(limit, need)
1176 const key = sharpKey(m)
1177 if (!source || want < m.width * 1.15 || (sharpening.get(key) ?? 0) >= want) return
1178 sharpening.set(key, want)
1179 void (async () => {
1180 const made = m.frames
1181 ? await prepareVideo($, source, m.title, await identityOf($, source), want)
1182 : await widenStill($, source, want)
1183 if (!made) return
1184 sharpest.set(key, { path: made.path, width: made.width, height: made.height, generation: made.generation, frames: made.frames })
1185 useSharpest(m)
1186 redraw($)
1187 })().catch(() => undefined)
1188}
1189async function widenStill($: EngineInterface, source: string, width: number): Promise<Shown | null> {
1190 const out = `${state.TMP}/w-${keyOf(source)}-${width}.png`
1191 const made = state.isMac
1192 ? await $.process.run(['sips', '-s', 'format', 'png', '--resampleWidth', String(width), source, '--out', out]).catch(() => null)
1193 : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', source, '-frames:v', '1', '-vf', `scale=${width}:-2:flags=lanczos`, out]).catch(() => null)
1194 if (!made || made.exitCode !== 0) return null
1195 state.generation += 1
1196 return { kind: 'image', path: out, source, ...(await pngSize($, out)), title: '', generation: state.generation }
1197}
1198// The window size the cell was last measured at (see Viewer).
1199let cellFor = ''
1200types/index.d.ts 69 lines1export type Frames = { dir: string; count: number; fps: number; duration: number; lead?: number; cut?: number }
2
3// One QA check: its name, whether it passed, and when it happens in a clip.
4export type Check = { name: string; ok: boolean; at?: number; url?: string }
5
6// One change to review: what changed, optionally when it shows in the clip and
7// the page it is on.
8export type KeyChange = { title: string; detail?: string; at?: number; url?: string }
9
10// One line of the QA summary: a group of checks, whether it passed, and how
11// many of its checks did.
12export type Summary = { label: string; ok: boolean; passed?: number; total?: number }
13
14// A play button over the centre of a paused video, or none. (A grid of
15// buttons over the whole picture was tried: it erases the picture.)
16export type Overlay = 'center' | 'none'
17
18export type Shown = {
19 kind?: 'image' | 'video'
20 // the PNG the terminal draws (a video's first frame)
21 path: string
22 // the file the user gave, for Open and Reveal
23 source?: string
24 /** The file as the user knows it (source is the kept copy). */
25 origin?: string
26 // the picture drawn (a video's frames), in pixels
27 width: number
28 height: number
29 /** The file's own size, for its label, when the picture drawn is another size. */
30 sourceWidth?: number
31 sourceHeight?: number
32 title: string
33 generation: number
34 frames?: Frames
35 // QA context: the change, its PR, the checks run
36 change?: string
37 pr?: string
38 // the page the recording or picture is of, to open it there
39 url?: string
40 checks?: Check[]
41 // what to review, and the QA in three lines
42 changes?: KeyChange[]
43 summary?: Summary[]
44 // the other side of a before/after, and the two sides' names
45 compare?: Shown
46 /** Earlier kept versions of the same file, oldest first; compare is the last. */
47 history?: { path: string; note?: string }[]
48 /** The heading over the changes list, when they aren't changes. */
49 changesTitle?: string
50 /** One line on this version, for the history list. */
51 note?: string
52 /** Which of history the compared side starts on. */
53 historyAt?: number
54 // Original and Updated merged into one clip, side by side, and its size
55 pair?: Frames
56 pairWidth?: number
57 pairHeight?: number
58 // moments that line up, [updated, original] seconds, matched by how the frames look
59 sync?: [number, number][]
60 label?: string
61 overlay?: Overlay
62}
63
64declare module 'claude-code' {
65 interface PluginState {
66 'show-your-work': { current: Shown | null; welcome: number }
67 }
68}
69