One workspace for watching and understanding Claude's work: a one-line band above the prompt and a pane with Now, Changes (file diffstat, changed functions…

A Claude Code plugin marketplace, and the single home for Claude customizations that stay the same on every device: ten mods, the Claritymaxx skill, the sync-home skill, and the karpathy-guidelines and python-standards coding skills. To add something, ask Claude to put it "on all my devices". The sync-home skill and CLAUDE.md tell it how.
| Plugin | Command | What it does |
|---|---|---|
| usage-forecast | /forecast, /forecast hide, /forecast show | A band above the prompt. 5-hour and Weekly plan limits as coloured bars (green, then yellow from 70%, red from 90%) with the percent used, a reset countdown and the reset time (↻ resets in 1h 12m · 15:40), and ⚠ at this pace, out in 40m when you would run out before the reset. Below it, the context window as weather with a 12-turn sparkline and turns left. Replaces token-weather. |
| blast-radius | /blast-radius, /blast-radius <command> (dry run) | Catches rm -rf, git reset --hard, force pushes, git clean -f, git checkout ., git branch -D, git stash drop/clear, DROP/TRUNCATE, mkfs, dd of=/dev/.... It shows what the command would touch in a pane and forces a permission prompt. A deny from your settings is never loosened. |
| replay-theater | /replay, /replay 2 | Records each turn's Edit and Write calls. In the pane, n/p step through the diffs, o/w change turn, and Esc closes it. |
| changed-files | /changed-files | A live sidebar of every file touched this session, with +/- counts, edit counts and a new tag. It opens by itself on the first edit. |
| session-meter | /meter | The status line shows elapsed time, tool calls and files edited. A toast appears when a turn takes 60 s or more; change it with /plugin configure session-meter@harshit-mods. |
| claritymaxx | /claritymaxx:explain <topic>, or ask "explain..." | Third-party skill from v60samurai/claritymaxx. Builds a mental model first, then explains it as text, a diagram or a small HTML page. |
| fork-explorer | /fork <what if...>, or type in the pane | Runs a side question over the current session and shows 2-3 alternative approaches as cards side by side, with pros, cons and effort. "use this" puts the choice in your prompt box to edit and send. Nothing is added to your conversation. |
| mission-control | /mission | A live pane of what Claude is executing: each tool call with its input, status badge (✓ ✗ ⊘ ●), duration and output; subagents in their own lanes with tool and token counts; a network-tab style waterfall; time spent in the model between tools. v switches between the timeline and the outputs. Observe-only; it never changes a call. |
| diff-minimap | automatic | A thin strip beside each Edit and Write row in the transcript. Green, red and yellow ticks show where in the file the change sits, with the line range and the file length. |
| codebase-atlas | /atlas, /atlas changes, /atlas components <file>, /atlas calls <name>, /atlas decisions, /atlas rescan | One pane with six tabs. Map: folders as boxes in dependency layers (entry points on top, foundations at the bottom), lit yellow when Claude edits and cyan when it reads; select one for what it imports and what uses it. Changes: every uncommitted change mapped to the functions and classes it added, modified or removed, with call-site counts, the layers crossed and the files that import what changed. Components: a file's classes, functions and methods with size bars, what each calls, and which changed. Calls: callers and callees of any function; type a name or select one in the transcript, then walk the graph. Decisions: a timeline of decisions a small model pulls from each turn (turn off with extractDecisions). Explain: side-question explanations of a folder, a function or the session's changes that never enter your conversation. Works with or without git: in a git repo, changes are measured against the last commit; in a plain folder, against the files as Atlas first read them that session (dependencies, virtual environments and build output are skipped). Reads Python and JS/TS. |
| workbench | /wb, /wb changes [file], /wb preview <file>, /wb artifacts, /wb map, /wb usage, /wb calls <name>, /wb forecast, /wb workbench, /wb hud off | One workspace. A one-line band above the prompt with two slides, switched with ◀ ▶: Workbench (session time, tool calls, files changed with +/−, new files, plan limits and context on wide terminals, then the running tool or the last edit and its functions) and Forecast (5-hour and weekly limits with bars, reset times and pace, plus the context forecast; it shows the usage-forecast mod's band when that mod is on). A pane with six tabs on one row: Now, the turn's tool calls with status and timing, failures with their error, and subagents nested under the call that started them; Changes, every file with a change bar and the functions it touched, then a file's changed functions and highlighted diff since Claude first touched it, then each edit (replay with n/p); Preview, a live render of the file being edited (Markdown, CSV table, JSON tree, HTML screenshot, PNG, code) with recent files one press away; Artifacts, files created this session by Claude ✦ or its commands, with preview and copy path; Map, architecture layers with folder sizes and imports, a file's components, and the call graph; Usage, limits with reset times and pace, context, and cost per turn. Works with or without git. Claude Code's own diff panel sits above mod panes: run /diff to hide it if it covers the workbench. |
| crew | /crew, /crew auto off, /crew close | An Agents side pane, like a mission board for subagents. Three cards on top total the session's cost, tokens and time. Each subagent is a little pixel crew member whose hat shows its effort (heavy, careful, medium, light), with its model and effort level, context used with a bar, tokens, its share of the cost and how long it ran; it walks while it works and shows the tool it is running. Sections: Running, Completed (✓ done, ✗ failed, ⊘ stopped; folds) and Planned, the tasks from Claude's task or todo list with what each still waits for ("after 1, 3"). Click an agent for its task, tool count and answer. Opens by itself when the first subagent starts; /crew auto off stops that. |
| explainer-page | /explainer-page:explainer-page, or ask for a visual or interactive explainer page | A skill for textbook-style HTML pages that teach one concept: each idea in words, maths, a figure and code, with colour-coded notation (inputs blue, outputs orange, parameters green, functions violet, probabilities pink) kept identical everywhere; tables and figures (3D bars, node diagrams) drawn from one data source and highlighted together on hover; sliders where a parameter is the point; code cells with their real output; margin notes, callouts and check-yourself questions; light and dark themes and a contents sidebar. Publishes as a claude.ai artifact when that tool is available, otherwise writes an .html file. Triggers only on an explicit request for such a page. |
| savvy-flow | /savvy-flow:savvy-flow <task> | A copy of savvy-flow from johnnyvizz/claude-kit with one change: the top tier, savvy-fable, runs on Opus at max effort instead of the Fable model, so it works without Fable access. The session model plans the task, delegates pieces to five tiered worker agents (savvy-fable, savvy-heavy, savvy-careful, savvy-medium, savvy-light) and reviews what they return. tools/sync-savvy-flow.sh refreshes the copy from upstream and reapplies the change; the Sync savvy-flow GitHub Action runs it daily and commits when upstream changed. |
| savvy-progress | /agents-info | From the same repo, following its main branch the same way. A progress bar above the prompt driven by savvy-flow, and an agents panel with model, effort, step progress, context, estimated cost and time. Animated crabs draw in the desktop app; the terminal shows text rows (crew draws its sprites in both). |
| sync-home | (automatic) or ask "add X to all my devices" | A skill that tells Claude this repository is where plugins, mods, skills and other customizations go so they sync to every device. Claude adds them under plugins/, validates, pushes and tells you what to turn on. |
| karpathy-guidelines | /karpathy-guidelines:clean-code, or automatic on any coding task | Guidelines from Andrej Karpathy's notes on LLM coding mistakes: think before coding, simplicity first, surgical changes, goal-driven execution, with worked examples in EXAMPLES.md. |
| python-standards | /python-standards:python-standards, or automatic on Python work | Python standards: SOLID, DRY, KISS and YAGNI; FastAPI project layout; naming; OWASP-aligned secure coding; linting and testing setup. |
Add this marketplace to your claude.ai account once:
harshitmywork17/claude-mods. If the repository is private, your GitHub account must be connected to Claude.sync-home too, so every session knows to put new synced customizations here.Plugins on your account sync at session start, on every machine where you sign in to Claude Code with that account. Mods need Claude Code v2.1.287 or later (claude --version, then claude update). After you push a change here, press Check for updates on the marketplace in claude.ai, and turn on any new plugin. Then start a new session.
If a command such as /wb is missing on a machine:
claude --version must be 2.1.287 or later.claude plugin list should show workbench@synced. If it doesn't, the sync hasn't picked it up: check for updates in claude.ai, or install on that machine as below.claude plugin test run in an empty folder should say no hooks module to load. Any other message means mods are turned off there.Mods run in Claude Code only: the terminal, the desktop app's Code tab, IDE extensions and cloud sessions set up as below. Claude chat on the web, desktop and mobile shows no mod UI. Claritymaxx is a skill, so it also works in Claude chat and Cowork.
Cloud sessions at claude.ai/code don't load account plugins or the plugins a repository lists in .claude/settings.json. They do load plugin folders named in CLAUDE_CODE_PLUGIN_DIRS. Set it once on the cloud environment:
CLAUDE_CODE_PLUGIN_DIRS=/home/user/claude-mods/plugins ``harshitmywork17/claude-mods alongside the repository you're working on. It is cloned to /home/user/claude-mods, and every plugin in plugins/ loads, including new ones.A cloud session can only clone the repositories selected for it, because this repository is private. In a session without it, the variable points at a missing folder, which Claude Code skips.
claude plugin marketplace add harshitmywork17/claude-mods
claude plugin install workbench@harshit-mods # repeat for each plugin
Or run every plugin from a local clone, picking up changes with git pull: add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "<path to clone>/plugins" } to ~/.claude/settings.json. Don't combine this with account sync on the same machine: when two copies share a name, only the first one loads.
Each mod has tests: claude plugin test plugins/<mod>. Check a mod with claude plugin validate plugins/<mod>. Increase version in a mod's plugin.json when you change it, because installed copies are cached by version.
hooks/register.tsx 2180 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderNode, Register, SessionContextUsage, SessionRateLimit, UiPressArgument } from 'claude-code'
3
4import type {
5 Artifact,
6 Baselines,
7 CallView,
8 EditHunk,
9 EditRecord,
10 FileSummary,
11 Graph,
12 Limit,
13 Outline,
14 Preview,
15 Reading,
16 Site,
17 Step,
18 Tab,
19 Turn,
20} from '../types'
21import { bodyOf, calleesIn, enclosingSymbol, isDefinitionLine, symbolFromSelection } from './calls'
22import { escapeRegex, innermostChanged, lineDiff, markChanged, outlineOf, symbolChanges } from './changes'
23import { changedRanges, fit, hunksText, unifiedDiff } from './diff'
24import type { Range } from './changes'
25import { HISTORY_LENGTH, kilo, outlook, sky, sparkline } from './forecast'
26import { KIND_LABEL, ago, artifactKind, bytes, htmlOutline, jsonTree, parseCsv, parseJson, previewKind } from './files'
27import { duration, meter, sortLimits, viewOf } from './limits'
28import {
29 IMPORT_PATTERN,
30 IMPORT_RE,
31 SKIPPED_DIRS,
32 SOURCE_GLOBS,
33 SYMBOL_PATTERN,
34 SYMBOL_RE,
35 buildScan,
36 countTexts,
37 grepLine,
38 grepMatches,
39 grepTexts,
40 groupOf,
41 isSource,
42 shortCount,
43} from './scan'
44import type { Scan } from './scan'
45import { C, diffstat, hasContent, head, hue, pill, tail } from './theme'
46import { BADGE, MAX_TURNS, modelTime, onNewest, preview, promptLabel, relabel, seconds, statusOf, summarize, withStep, withStepChange } from './trace'
47
48// ── State ───────────────────────────────────────────────────────────────
49
50const tabAtom = atom({ plugin: 'workbench', key: 'tab' } as const, 'now')
51const hudHiddenAtom = atom({ plugin: 'workbench', key: 'isHudHidden' } as const, false)
52const slideAtom = atom({ plugin: 'workbench', key: 'slide' } as const, 'workbench')
53const startedAtAtom = atom({ plugin: 'workbench', key: 'startedAt' } as const, 0)
54const turnsAtom = atom({ plugin: 'workbench', key: 'turns' } as const, [])
55const turnBackAtom = atom({ plugin: 'workbench', key: 'turnBack' } as const, 0)
56const showOutputsAtom = atom({ plugin: 'workbench', key: 'showOutputs' } as const, false)
57const editsAtom = atom({ plugin: 'workbench', key: 'edits' } as const, [])
58const changeFileAtom = atom({ plugin: 'workbench', key: 'changeFile' } as const, null)
59const changeEditAtom = atom({ plugin: 'workbench', key: 'changeEdit' } as const, null)
60const fileSummaryAtom = atom({ plugin: 'workbench', key: 'fileSummary' } as const, null)
61const previewAtom = atom({ plugin: 'workbench', key: 'preview' } as const, null)
62const followAtom = atom({ plugin: 'workbench', key: 'isFollowing' } as const, true)
63const artifactsAtom = atom({ plugin: 'workbench', key: 'artifacts' } as const, [])
64const graphAtom = atom({ plugin: 'workbench', key: 'graph' } as const, null)
65const scanStatusAtom = atom({ plugin: 'workbench', key: 'scanStatus' } as const, 'idle')
66const scanErrorAtom = atom({ plugin: 'workbench', key: 'scanError' } as const, '')
67const mapViewAtom = atom({ plugin: 'workbench', key: 'mapView' } as const, 'map')
68const mapFocusAtom = atom({ plugin: 'workbench', key: 'mapFocus' } as const, null)
69const outlineAtom = atom({ plugin: 'workbench', key: 'outline' } as const, null)
70const callAtom = atom({ plugin: 'workbench', key: 'call' } as const, null)
71const trailAtom = atom({ plugin: 'workbench', key: 'trail' } as const, [])
72const limitsAtom = atom({ plugin: 'workbench', key: 'limits' } as const, [])
73const readingAtom = atom({ plugin: 'workbench', key: 'reading' } as const, null)
74const historyAtom = atom({ plugin: 'workbench', key: 'history' } as const, [])
75const costAtom = atom({ plugin: 'workbench', key: 'costUsd' } as const, null)
76/** Each file as it was before Claude first touched it this session, in state so a reload keeps it. */
77const baselinesAtom = atom({ plugin: 'workbench', key: 'baselines' } as const, {})
78
79const PANE = 'workbench'
80const TITLE = 'Workbench'
81/** The sidebar width asked for: room for the tabs, a diff and the change bars on one line. */
82const PANE_COLUMNS = 76
83const MAX_EDITS = 300
84/** Larger files keep no baseline: their whole-file diff falls back to the recorded edits. */
85const MAX_BASELINE_CHARS = 400_000
86const MAX_HUNK_LINES = 400
87const MAX_PREVIEW_CHARS = 200_000
88const MAX_CODE_CHARS = 9000
89const MAX_MARKDOWN_CHARS = 30_000
90const MAX_CSV_ROWS = 40
91const MAX_ARTIFACTS = 200
92const MAX_WALK_FILES = 5000
93const MAX_WALK_DIRS = 3000
94const MAX_FOLDER_FILES = 1500
95const MAX_FILE_BYTES = 512 * 1024
96const MAX_CALLERS = 40
97const GIT_TIMEOUT_MS = 20_000
98const SCREENSHOT_TIMEOUT_MS = 30_000
99const BROWSERS = [
100 'chromium',
101 'chromium-browser',
102 'google-chrome',
103 'google-chrome-stable',
104 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
105 '/Applications/Chromium.app/Contents/MacOS/Chromium',
106 '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
107 'msedge',
108]
109
110type Source = { root: string; isGit: boolean }
111type ListedFile = { path: string; size: number; mtimeMs: number }
112
113let cwd = ''
114let source: Source | null = null
115let cache: Scan | null = null
116let isScanning = false
117/** Folder mode: each source file's text and modification time as last read. */
118const current = new Map<string, { text: string; mtimeMs: number }>()
119/** Every file in the folder when the session began: what Artifacts compares against. */
120let startListing: Set<string> | null = null
121const claudeCreated = new Set<string>()
122/** The browser used for HTML screenshots: undefined until looked for, null when none was found. */
123let browser: string | null | undefined
124let isBusy = false
125let turnCostStart: number | null = null
126let previewGeneration = 0
127let hasHinted = false
128
129// ── Files and folders ───────────────────────────────────────────────────
130
131type GitResult = { isOk: boolean; out: string; err: string }
132
133async function git($: EngineInterface, root: string, args: readonly string[]): Promise<GitResult> {
134 const run = await $.process.run(['git', '-C', root, ...args], { timeoutMs: GIT_TIMEOUT_MS })
135 const isEmptyGrep = args[0] === 'grep' && run.exitCode === 1 && run.stderr.trim() === ''
136 return { isOk: run.exitCode === 0 || isEmptyGrep, out: run.stdout, err: run.stderr.trim() }
137}
138
139/** The folder Workbench reads: the git repository around the session, or else the session's own folder. */
140async function findSource($: EngineInterface): Promise<Source | null> {
141 if (source !== null) return source
142 try {
143 const run = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: cwd || undefined, timeoutMs: 5000 })
144 if (run.exitCode === 0 && run.stdout.trim() !== '') {
145 source = { root: run.stdout.trim(), isGit: true }
146 return source
147 }
148 } catch {
149 // No git on this machine: read the folder directly.
150 }
151 if (cwd === '') return null
152 source = { root: cwd.replace(/\/+$/, ''), isGit: false }
153 return source
154}
155
156function relative(path: string, root: string): string {
157 return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
158}
159
160function absolute(path: string, root: string): string {
161 return path.startsWith('/') ? path : `${root}/${path}`
162}
163
164function lineTotal(text: string): number {
165 if (text === '') return 0
166 const lines = text.split('\n')
167 return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
168}
169
170async function readText($: EngineInterface, path: string): Promise<string> {
171 try {
172 return await $.fs.read(path)
173 } catch {
174 return ''
175 }
176}
177
178async function rootOf($: EngineInterface): Promise<string> {
179 return (await findSource($))?.root ?? cwd
180}
181
182/** Every file under the root, leaving out dependencies, environments, caches, build output and hidden folders. */
183async function walk($: EngineInterface, root: string, isWanted: (name: string, size: number) => boolean, maxFiles: number): Promise<ListedFile[]> {
184 const found: ListedFile[] = []
185 const queue = ['']
186 let dirs = 0
187 while (queue.length > 0 && found.length < maxFiles && dirs < MAX_WALK_DIRS) {
188 const dir = queue.shift() ?? ''
189 dirs += 1
190 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
191 try {
192 entries = await $.fs.list(dir === '' ? root : `${root}/${dir}`)
193 } catch {
194 continue
195 }
196 if (dir !== '' && entries.some(entry => entry.name === 'pyvenv.cfg')) continue
197 for (const entry of entries) {
198 const path = dir === '' ? entry.name : `${dir}/${entry.name}`
199 if (entry.kind === 'dir' && !entry.isLink && !entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name)) queue.push(path)
200 else if (entry.kind === 'file' && isWanted(entry.name, entry.size)) found.push({ path, size: entry.size, mtimeMs: entry.mtimeMs })
201 }
202 }
203 return found.slice(0, maxFiles)
204}
205
206// ── Code Map: scanning, components and calls ───────────────────────────
207
208async function syncFolder($: EngineInterface, root: string): Promise<void> {
209 const listed = await walk($, root, (name, size) => isSource(name) && size <= MAX_FILE_BYTES, MAX_FOLDER_FILES)
210 const seen = new Set<string>()
211 for (const file of listed) {
212 seen.add(file.path)
213 if (current.get(file.path)?.mtimeMs === file.mtimeMs) continue
214 current.set(file.path, { text: await readText($, `${root}/${file.path}`), mtimeMs: file.mtimeMs })
215 }
216 for (const path of [...current.keys()]) if (!seen.has(path)) current.delete(path)
217}
218
219function currentTexts(): Map<string, string> {
220 return new Map([...current].map(([path, file]) => [path, file.text]))
221}
222
223async function scan($: EngineInterface): Promise<void> {
224 if (isScanning) return
225 isScanning = true
226 await update($, scanStatusAtom, () => 'scanning')
227 try {
228 cache = null
229 const src = await findSource($)
230 if (src === null) {
231 await update($, scanStatusAtom, () => 'no-folder')
232 return
233 }
234 if (src.isGit) {
235 const files = await git($, src.root, ['ls-files', '--', ...SOURCE_GLOBS])
236 if (!files.isOk) throw new Error(files.err || 'git ls-files failed')
237 const counts = await git($, src.root, ['grep', '-c', '-I', '', '--', ...SOURCE_GLOBS])
238 const imports = await git($, src.root, ['grep', '-n', '-I', '-E', IMPORT_PATTERN, '--', ...SOURCE_GLOBS])
239 const symbols = await git($, src.root, ['grep', '-n', '-I', '-E', SYMBOL_PATTERN, '--', ...SOURCE_GLOBS])
240 cache = buildScan(src.root, files.out, counts.out, imports.out, symbols.out, true)
241 } else {
242 await syncFolder($, src.root)
243 const texts = currentTexts()
244 cache = buildScan(src.root, [...texts.keys()].join('\n'), countTexts(texts), grepTexts(texts, IMPORT_RE), grepTexts(texts, SYMBOL_RE), false)
245 }
246 const graph = cache.graph
247 await update($, graphAtom, () => graph)
248 await update($, scanStatusAtom, () => 'ready')
249 } catch (error) {
250 await update($, scanErrorAtom, () => String(error))
251 await update($, scanStatusAtom, () => 'error')
252 } finally {
253 isScanning = false
254 }
255}
256
257async function ensureScan($: EngineInterface): Promise<Scan | null> {
258 if (cache === null) await scan($)
259 return cache
260}
261
262async function searchSources($: EngineInterface, src: Source, gitPattern: string, jsPattern: RegExp): Promise<string> {
263 if (src.isGit) return (await git($, src.root, ['grep', '-n', '-I', '-E', gitPattern, '--', ...SOURCE_GLOBS])).out
264 return grepTexts(currentTexts(), jsPattern)
265}
266
267/** Lines changed this session in a file, from the text it had before Claude first touched it. */
268async function sessionRanges($: EngineInterface, path: string, text: string): Promise<Range[]> {
269 const baselines = await read($, baselinesAtom)
270 if (!(path in baselines)) return []
271 return lineDiff(path, baselines[path] ?? null, text).ranges
272}
273
274async function loadOutline($: EngineInterface, path: string): Promise<void> {
275 await update($, outlineAtom, (): Outline => ({ file: path, status: 'loading', lines: 0, entries: [] }))
276 await update($, tabAtom, (): Tab => 'map')
277 await update($, mapViewAtom, () => 'components')
278 try {
279 const root = await rootOf($)
280 const scanned = await ensureScan($)
281 const text = await readText($, absolute(path, root))
282 if (text === '') throw new Error(`Could not read ${path}.`)
283 const entries = markChanged(outlineOf(text, path), await sessionRanges($, path, text)).map(entry => ({
284 ...entry,
285 calls:
286 entry.kind === 'class'
287 ? []
288 : calleesIn(bodyOf(text, entry.line, path), entry.name, scanned?.symbols ?? new Map())
289 .filter(site => site.isKnown)
290 .map(site => site.symbol)
291 .slice(0, 8),
292 }))
293 await update($, outlineAtom, (): Outline => ({ file: path, status: 'ready', lines: lineTotal(text), entries }))
294 } catch (error) {
295 await update($, outlineAtom, (): Outline => ({ file: path, status: 'error', lines: 0, entries: [], error: String(error) }))
296 }
297}
298
299async function loadCalls($: EngineInterface, symbol: string, isFromTrail = false): Promise<void> {
300 const name = symbol.trim()
301 await update($, tabAtom, (): Tab => 'map')
302 await update($, mapViewAtom, () => 'calls')
303 if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
304 await update($, callAtom, (): CallView => ({ symbol: name, status: 'error', callers: [], callees: [], error: 'Type one function or class name.' }))
305 return
306 }
307 await update($, callAtom, (): CallView => ({ symbol: name, status: 'loading', callers: [], callees: [] }))
308 if (!isFromTrail) await update($, trailAtom, trail => [...trail.filter(one => one !== name), name].slice(-20))
309 try {
310 const src = await findSource($)
311 if (src === null) throw new Error('Workbench could not tell which folder to read.')
312 const scanned = await ensureScan($)
313 const index = scanned?.symbols ?? new Map<string, Site[]>()
314 const definition = index.get(name)?.[0]
315 const texts = new Map<string, string>()
316 const textOf = async (file: string): Promise<string> => {
317 if (!texts.has(file)) texts.set(file, current.get(file)?.text ?? (await readText($, `${src.root}/${file}`)))
318 return texts.get(file) ?? ''
319 }
320 const escaped = escapeRegex(name)
321 const hits = await searchSources($, src, `(^|[^A-Za-z0-9_$])${escaped}[[:space:]]*\\(`, new RegExp(`(^|[^A-Za-z0-9_$])${escaped}\\s*\\(`))
322 const callers: Site[] = []
323 const seen = new Set<string>()
324 for (const row of hits.split('\n')) {
325 const hit = grepLine(row)
326 if (hit === null || isDefinitionLine(hit.text, name)) continue
327 const caller = enclosingSymbol(await textOf(hit.file), hit.line, hit.file)
328 if (seen.has(`${caller}@${hit.file}`)) continue
329 seen.add(`${caller}@${hit.file}`)
330 callers.push({ symbol: caller, file: hit.file, line: hit.line, isKnown: index.has(caller) })
331 if (callers.length >= MAX_CALLERS) break
332 }
333 const callees = definition === undefined ? [] : calleesIn(bodyOf(await textOf(definition.file), definition.line, definition.file), name, index)
334 const view: CallView = { symbol: name, status: definition === undefined && callers.length === 0 ? 'missing' : 'ready', definition, callers, callees }
335 await update($, callAtom, () => view)
336 } catch (error) {
337 await update($, callAtom, (): CallView => ({ symbol: name, status: 'error', callers: [], callees: [], error: String(error) }))
338 }
339}
340
341// ── Changes: recording edits ────────────────────────────────────────────
342
343type FileResult = {
344 filePath?: string
345 structuredPatch?: readonly EditHunk[]
346 originalFile?: string | null
347 content?: string
348 type?: 'create' | 'update'
349 staged?: boolean
350}
351
352function hunksOf(result: FileResult): { hunks: EditHunk[]; isCut: boolean } {
353 let hunks: EditHunk[] = (result.structuredPatch ?? []).map(hunk => ({ ...hunk, lines: [...hunk.lines] }))
354 if (hunks.length === 0 && result.type === 'create' && result.content !== undefined) {
355 const lines = result.content.replace(/\n$/, '').split('\n')
356 hunks = [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map(line => `+${line}`) }]
357 }
358 let budget = MAX_HUNK_LINES
359 let isCut = false
360 const kept: EditHunk[] = []
361 for (const hunk of hunks) {
362 if (budget <= 0) {
363 isCut = true
364 break
365 }
366 const lines = hunk.lines.slice(0, budget).map(line => (line.length > 400 ? `${line.slice(0, 400)}…` : line))
367 if (lines.length < hunk.lines.length) isCut = true
368 budget -= lines.length
369 kept.push({ ...hunk, lines })
370 }
371 return { hunks: kept, isCut }
372}
373
374async function recordEdit($: EngineInterface, tool: string, input: Record<string, unknown>, result: FileResult, turnId: string): Promise<EditRecord | null> {
375 if (result.staged === true) return null
376 const root = await rootOf($)
377 const abs = typeof result.filePath === 'string' ? result.filePath : typeof input.file_path === 'string' ? input.file_path : null
378 if (abs === null) return null
379 const file = relative(abs, root)
380 const isCreate = result.type === 'create'
381 const baseline = isCreate ? null : (result.originalFile ?? null)
382 if (!(file in (await read($, baselinesAtom))) && (baseline === null || baseline.length <= MAX_BASELINE_CHARS)) {
383 await update($, baselinesAtom, (all): Baselines => ({ ...all, [file]: baseline }))
384 }
385 if (isCreate) claudeCreated.add(file)
386
387 const { hunks, isCut } = hunksOf(result)
388 const lines = hunks.flatMap(hunk => hunk.lines)
389 const text = await readText($, abs)
390 const symbols = innermostChanged(markChanged(outlineOf(text, file), changedRanges(hunks))).map(entry => entry.name)
391 const record: EditRecord = {
392 id: `${tool}-${await $.clock.now()}-${Math.random().toString(36).slice(2, 7)}`,
393 turnId,
394 file,
395 kind: tool === 'Write' ? (isCreate ? 'create' : 'overwrite') : 'edit',
396 hunks,
397 added: lines.filter(line => line.startsWith('+')).length,
398 removed: lines.filter(line => line.startsWith('-')).length,
399 symbols: [...new Set(symbols)],
400 at: await $.clock.now(),
401 isCut,
402 }
403 await update($, editsAtom, list => [...list, record].slice(-MAX_EDITS))
404 return record
405}
406
407async function loadFileSummary($: EngineInterface, file: string): Promise<void> {
408 await update($, fileSummaryAtom, (): FileSummary => ({ file, status: 'loading', symbols: [], added: 0, removed: 0, diff: '', isDiffCut: false }))
409 try {
410 const root = await rootOf($)
411 const after = await readText($, absolute(file, root))
412 const baselines = await read($, baselinesAtom)
413 if (!(file in baselines)) {
414 // No baseline kept (a very large file): show the recorded edits one after another instead.
415 const mine = (await read($, editsAtom)).filter(edit => edit.file === file)
416 const joined = hunksText(mine.flatMap(edit => edit.hunks))
417 const summary: FileSummary = {
418 file,
419 status: 'ready',
420 symbols: [],
421 added: mine.reduce((sum, edit) => sum + edit.added, 0),
422 removed: mine.reduce((sum, edit) => sum + edit.removed, 0),
423 diff: joined.text,
424 isDiffCut: joined.isCut,
425 }
426 await update($, fileSummaryAtom, () => summary)
427 return
428 }
429 const before = baselines[file] ?? null
430 const diff = lineDiff(file, before, after === '' && before !== null ? null : after)
431 const symbols = symbolChanges(outlineOf(after, file), outlineOf(before ?? '', file), diff.ranges)
432 const unified = unifiedDiff(before, after === '' && before !== null ? null : after)
433 await update($, fileSummaryAtom, (): FileSummary => ({ file, status: 'ready', symbols, added: diff.added, removed: diff.removed, diff: unified.text, isDiffCut: unified.isCut }))
434 } catch (error) {
435 await update($, fileSummaryAtom, (): FileSummary => ({ file, status: 'error', symbols: [], added: 0, removed: 0, diff: '', isDiffCut: false, error: String(error) }))
436 }
437}
438
439async function openFile($: EngineInterface, file: string): Promise<void> {
440 await update($, tabAtom, (): Tab => 'changes')
441 await update($, changeFileAtom, () => file)
442 await update($, changeEditAtom, () => null)
443 await loadFileSummary($, file)
444}
445
446// ── Preview ─────────────────────────────────────────────────────────────
447
448function hashOf(text: string): string {
449 let hash = 5381
450 for (let index = 0; index < text.length; index += 1) hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0
451 return hash.toString(36)
452}
453
454async function findBrowser($: EngineInterface): Promise<string | null> {
455 if (browser !== undefined) return browser
456 for (const candidate of BROWSERS) {
457 try {
458 const run = await $.process.run([candidate, '--version'], { timeoutMs: 5000 })
459 if (run.exitCode === 0) {
460 browser = candidate
461 return browser
462 }
463 } catch {
464 // Not installed under this name; try the next.
465 }
466 }
467 browser = null
468 return browser
469}
470
471/** Renders an HTML file to a PNG with a headless browser; null when no browser is available or it fails. */
472async function screenshot($: EngineInterface, abs: string): Promise<string | null> {
473 const binary = await findBrowser($)
474 if (binary === null) return null
475 const out = `/tmp/workbench-preview-${hashOf(abs)}.png`
476 try {
477 const run = await $.process.run(
478 [binary, '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars', '--window-size=1280,800', `--screenshot=${out}`, `file://${abs}`],
479 { timeoutMs: SCREENSHOT_TIMEOUT_MS },
480 )
481 return run.exitCode === 0 ? out : null
482 } catch {
483 return null
484 }
485}
486
487async function openPreview($: EngineInterface, file: string, isFocus = true): Promise<void> {
488 previewGeneration += 1
489 const generation = previewGeneration
490 const kind = previewKind(file)
491 await update($, previewAtom, (): Preview => ({ path: file, kind, status: 'loading', text: '', size: 0, generation }))
492 if (isFocus) await update($, tabAtom, (): Tab => 'preview')
493 try {
494 const root = await rootOf($)
495 const abs = absolute(file, root)
496 const stat = await $.fs.stat(abs)
497 let next: Preview = { path: file, kind, status: 'ready', text: '', size: stat.size, generation }
498 if (kind === 'image') {
499 next = abs.toLowerCase().endsWith('.png') ? { ...next, image: abs } : { ...next, note: 'Only PNG images draw in the terminal; open the file to see it.' }
500 } else if (kind === 'html') {
501 const text = (await readText($, abs)).slice(0, MAX_PREVIEW_CHARS)
502 const shot = await screenshot($, abs)
503 next = { ...next, text, image: shot ?? undefined, note: shot === null ? 'No headless Chrome or Chromium found: showing the page outline.' : undefined }
504 } else {
505 next = { ...next, text: (await readText($, abs)).slice(0, MAX_PREVIEW_CHARS) }
506 }
507 if (generation === previewGeneration) await update($, previewAtom, () => next)
508 } catch (error) {
509 if (generation === previewGeneration) {
510 await update($, previewAtom, (): Preview => ({ path: file, kind, status: 'error', text: String(error), size: 0, generation }))
511 }
512 }
513}
514
515// ── Artifacts ───────────────────────────────────────────────────────────
516
517async function snapshotFolder($: EngineInterface): Promise<void> {
518 const root = await rootOf($)
519 if (root === '') return
520 startListing = new Set((await walk($, root, () => true, MAX_WALK_FILES)).map(file => file.path))
521}
522
523async function refreshArtifacts($: EngineInterface): Promise<void> {
524 const root = await rootOf($)
525 if (root === '') return
526 const listed = await walk($, root, () => true, MAX_WALK_FILES)
527 // Files Claude wrote come from the edit record as well, which outlives a reload of this module.
528 const created = new Set([...claudeCreated, ...(await read($, editsAtom)).filter(edit => edit.kind === 'create').map(edit => edit.file)])
529 if (startListing === null) {
530 startListing = new Set(listed.map(file => file.path).filter(path => !created.has(path)))
531 }
532 const before = startListing
533 const made: Artifact[] = listed
534 .filter(file => !before.has(file.path) || created.has(file.path))
535 .map(file => ({ path: file.path, kind: artifactKind(file.path), size: file.size, mtimeMs: file.mtimeMs, isByClaude: created.has(file.path) }))
536 .sort((a, b) => b.mtimeMs - a.mtimeMs)
537 .slice(0, MAX_ARTIFACTS)
538 await update($, artifactsAtom, () => made)
539}
540
541// ── Usage ───────────────────────────────────────────────────────────────
542
543function toReading(context: SessionContextUsage): Reading | null {
544 if (context.percent === undefined) return null
545 return { percent: context.percent, tokens: context.tokens ?? 0, window: context.window }
546}
547
548function toLimits(windows: readonly SessionRateLimit[]): Limit[] {
549 return sortLimits(windows.map(window => ({ kind: window.kind, percentUsed: window.percentUsed, resetsAt: window.resetsAt })))
550}
551
552// ── Views ───────────────────────────────────────────────────────────────
553
554type T = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown' | 'Code'>
555
556type Action = { key: string; hotkey: string; label: string; onPress: (press: UiPressArgument) => unknown }
557
558const TABS: { tab: Tab; label: string; key: string }[] = [
559 { tab: 'now', label: 'Now', key: '1' },
560 { tab: 'changes', label: 'Changes', key: '2' },
561 { tab: 'preview', label: 'Preview', key: '3' },
562 { tab: 'artifacts', label: 'Artifacts', key: '4' },
563 { tab: 'map', label: 'Map', key: '5' },
564 { tab: 'usage', label: 'Usage', key: '6' },
565]
566
567const CHANGE_MARK = { added: ['+', C.ok], modified: ['~', C.warn], removed: ['−', C.bad] } as const
568const KIND_ICON_CODE = { class: '◆', function: 'ƒ', method: '·' } as const
569const TURN_STATUS = { running: ['● running', C.live], done: ['✓ done', C.ok], interrupted: ['⊘ stopped', C.warn], failed: ['✗ failed', C.bad] } as const
570
571function clamp(value: number, low: number, high: number): number {
572 return Math.max(low, Math.min(high, value))
573}
574
575function turnNumber(turns: readonly Turn[], turnId: string): string {
576 const index = turns.findIndex(turn => turn.id === turnId)
577 return index < 0 ? 'T–' : `T${index + 1}`
578}
579
580/** A section title, a rule to the right edge, and an optional note at the end: TITLE ──────── note */
581function section(t: T, title: string, width: number, note = '', noteColor: string = C.soft): RenderNode {
582 const { Box, Text } = t
583 const label = head(title, Math.max(4, width - 8))
584 const room = width - label.length - 1 - (note === '' ? 0 : note.length + 1)
585 return (
586 <Box flexDirection="row" marginTop={1}>
587 <Text bold color={C.accent}>
588 {label}
589 </Text>
590 <Text color={C.line}> {'─'.repeat(Math.max(2, room))}</Text>
591 {note !== '' && <Text color={noteColor}> {note}</Text>}
592 </Box>
593 )
594}
595
596/** The key legend at the bottom of a view: each action a plain button drawn as `k: label`. */
597function footer(t: T, actions: readonly Action[], width: number): RenderNode | null {
598 const { Box, Button, Text } = t
599 if (actions.length === 0) return null
600 return (
601 <Box flexDirection="column" marginTop={1}>
602 <Text color={C.line}>{'─'.repeat(width)}</Text>
603 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
604 {actions.map(action => (
605 <Button key={action.key} label={action.label} hotkey={action.hotkey} plain dimColor onPress={action.onPress} />
606 ))}
607 </Box>
608 </Box>
609 )
610}
611
612function empty(t: T, icon: string, title: string, hint: string): RenderNode {
613 const { Box, Text } = t
614 return (
615 <Box flexDirection="column" marginTop={1} paddingX={1}>
616 <Text bold color={C.soft}>
617 {icon} {title}
618 </Text>
619 <Text dimColor>{hint}</Text>
620 </Box>
621 )
622}
623
624/** Fixed-width cell: content truncated, never wrapped, so columns line up. */
625function cell(t: T, width: number, child: RenderNode, isRight = false): RenderNode {
626 const { Box } = t
627 return (
628 <Box width={width} flexShrink={0} justifyContent={isRight ? 'flex-end' : 'flex-start'}>
629 {child}
630 </Box>
631 )
632}
633
634function plusMinus(t: T, added: number, removed: number): RenderNode {
635 const { Text } = t
636 return (
637 <Text>
638 <Text color={C.ok}>+{added}</Text> <Text color={C.bad}>−{removed}</Text>
639 </Text>
640 )
641}
642
643// ── Now ─────────────────────────────────────────────────────────────────
644
645function stepColor(step: Step, now: number): string {
646 if (step.status === 'running') return C.live
647 if (step.status === 'error') return C.bad
648 if (step.status === 'denied') return C.pink
649 const took = (step.endedAt ?? now) - step.startedAt
650 return took >= 30_000 ? C.bad : took >= 10_000 ? C.warn : C.soft
651}
652
653function stepRows(t: T, step: Step, now: number, width: number, indent: number, showOutputs: boolean): RenderNode {
654 const { Box, Text } = t
655 const color = stepColor(step, now)
656 const badge = step.status === 'ok' ? C.ok : color
657 const lead = indent + 2
658 const summaryWidth = Math.max(6, width - lead - 8 - 8)
659 const isProblem = step.status === 'error' || step.status === 'denied'
660 const output = step.output.replace(/\s*\n\s*/g, ' ⏎ ')
661 return (
662 <Box key={`step-${step.id}`} flexDirection="column">
663 <Box flexDirection="row">
664 {cell(t, lead, <Text color={badge}>{`${' '.repeat(indent)}${BADGE[step.status]}`}</Text>)}
665 {cell(
666 t,
667 8,
668 <Text bold color={C.text} wrap="truncate-end">
669 {step.tool}
670 </Text>,
671 )}
672 {cell(
673 t,
674 summaryWidth,
675 <Text color={C.soft} wrap="truncate-end">
676 {step.summary === '' ? '—' : step.summary}
677 </Text>,
678 )}
679 {cell(t, 8, <Text color={color}>{seconds((step.endedAt ?? now) - step.startedAt)}</Text>, true)}
680 </Box>
681 {(showOutputs || isProblem) && output !== '' && (
682 <Box paddingLeft={lead}>
683 <Text color={isProblem ? C.bad : C.line} wrap="truncate-end">
684 ↳ {output}
685 </Text>
686 </Box>
687 )}
688 </Box>
689 )
690}
691
692function nowView($: EngineInterface, t: T, turns: readonly Turn[], back: number, showOutputs: boolean, now: number, width: number): RenderNode {
693 const { Box, Text } = t
694 const offset = Math.min(back, Math.max(0, turns.length - 1))
695 const turn = turns[turns.length - 1 - offset]
696 if (turn === undefined) {
697 return empty(t, '●', 'Waiting for the first turn', 'Every tool call shows here as it runs: what ran, how long it took, its output, and the subagents it started.')
698 }
699
700 const total = (turn.endedAt ?? now) - turn.startedAt
701 const failed = turn.steps.filter(step => step.status === 'error' || step.status === 'denied').length
702 const counts = new Map<string, number>()
703 for (const step of turn.steps) counts.set(step.tool, (counts.get(step.tool) ?? 0) + 1)
704 const [statusText, statusColor] = TURN_STATUS[turn.status]
705
706 // Subagent lanes sit under the Agent call that started them; lanes still unclaimed follow the main steps.
707 const claimed = new Set(turn.steps.map(step => step.agent?.agentId).filter((id): id is string => id !== undefined))
708 const loose = turn.lanes.filter(lane => lane.id !== 'main' && !claimed.has(lane.id) && turn.steps.some(step => step.lane === lane.id))
709 const laneSteps = (id: string): Step[] => turn.steps.filter(step => step.lane === id)
710
711 return (
712 <Box flexDirection="column">
713 <Box flexDirection="row" justifyContent="space-between">
714 <Text>
715 <Text bold color={C.text}>
716 Turn {turns.length - offset}
717 </Text>
718 <Text color={C.soft}> of {turns.length}</Text>
719 </Text>
720 <Text>
721 <Text bold color={statusColor}>
722 {statusText}
723 </Text>
724 <Text color={C.soft}> · {seconds(total)}</Text>
725 </Text>
726 </Box>
727 <Text italic color={C.soft} wrap="truncate-end">
728 “{turn.prompt}”
729 </Text>
730 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
731 {[...counts].map(([tool, count]) => (
732 <Text key={`count-${tool}`} backgroundColor={C.chip} color={C.text}>
733 {` ${tool} ${count} `}
734 </Text>
735 ))}
736 <Text color={C.soft}>model {seconds(modelTime(turn, now))}</Text>
737 </Box>
738 {section(t, 'STEPS', width, `${turn.steps.length} call${turn.steps.length === 1 ? '' : 's'}${failed > 0 ? ` · ${failed} failed` : ''}`, failed > 0 ? C.bad : C.soft)}
739 {turn.steps.length === 0 && <Text dimColor> Thinking: no tool calls yet.</Text>}
740 {laneSteps('main').map(step => (
741 <Box key={`main-${step.id}`} flexDirection="column">
742 {stepRows(t, step, now, width, 0, showOutputs)}
743 {step.agent !== undefined && (
744 <Box flexDirection="column">
745 <Text color={C.accent} wrap="truncate-end">
746 {' '}⑂ {step.agent.type}
747 {step.agent.toolCount === undefined ? '' : ` · ${step.agent.toolCount} tools`}
748 {step.agent.tokens === undefined ? '' : ` · ${kilo(step.agent.tokens)} tokens`}
749 </Text>
750 {step.agent.agentId !== undefined && laneSteps(step.agent.agentId).map(inner => stepRows(t, inner, now, width, 2, showOutputs))}
751 </Box>
752 )}
753 </Box>
754 ))}
755 {loose.map(lane => (
756 <Box key={`lane-${lane.id}`} flexDirection="column">
757 <Text color={C.accent} wrap="truncate-end">
758 {' '}⑂ {lane.label}
759 </Text>
760 {laneSteps(lane.id).map(inner => stepRows(t, inner, now, width, 2, showOutputs))}
761 </Box>
762 ))}
763 {footer(
764 t,
765 [
766 { key: 'now-outputs', hotkey: 'v', label: showOutputs ? 'hide outputs' : 'show outputs', onPress: () => update($, showOutputsAtom, value => !value) },
767 ...(offset < turns.length - 1 ? [{ key: 'now-older', hotkey: 'o', label: 'older turn', onPress: () => update($, turnBackAtom, n => n + 1) }] : []),
768 ...(offset > 0 ? [{ key: 'now-newer', hotkey: 'w', label: 'newer turn', onPress: () => update($, turnBackAtom, n => n - 1) }] : []),
769 ],
770 width,
771 )}
772 </Box>
773 )
774}
775
776// ── Changes ─────────────────────────────────────────────────────────────
777
778type FileStat = { file: string; added: number; removed: number; isNew: boolean; symbols: string[]; edits: EditRecord[] }
779
780function fileStats(edits: readonly EditRecord[]): FileStat[] {
781 const byFile = new Map<string, FileStat>()
782 for (const edit of edits) {
783 const stat = byFile.get(edit.file) ?? { file: edit.file, added: 0, removed: 0, isNew: false, symbols: [], edits: [] }
784 stat.added += edit.added
785 stat.removed += edit.removed
786 stat.isNew = stat.isNew || edit.kind === 'create'
787 stat.symbols = [...new Set([...stat.symbols, ...edit.symbols])]
788 stat.edits.push(edit)
789 byFile.set(edit.file, stat)
790 }
791 return [...byFile.values()].sort((a, b) => a.file.localeCompare(b.file))
792}
793
794function changesOverview($: EngineInterface, t: T, edits: readonly EditRecord[], turns: readonly Turn[], width: number): RenderNode {
795 const { Box, Button, Text } = t
796 if (edits.length === 0) {
797 return empty(t, '±', 'No changes yet', 'Each file Claude edits appears here with the functions it touched and a highlighted diff. Replay walks through every edit in order.')
798 }
799 const files = fileStats(edits)
800 const added = files.reduce((sum, file) => sum + file.added, 0)
801 const removed = files.reduce((sum, file) => sum + file.removed, 0)
802 const most = Math.max(1, ...files.map(file => file.added + file.removed))
803 const turnIds = [...new Set(edits.map(edit => edit.turnId))].reverse()
804 const barWidth = width >= 70 ? 12 : 8
805 const pathWidth = Math.max(10, width - 3 - 7 - 7 - barWidth - 1)
806
807 return (
808 <Box flexDirection="column">
809 <Box flexDirection="row" justifyContent="space-between">
810 <Text>
811 <Text bold color={C.text}>
812 {files.length} file{files.length === 1 ? '' : 's'} changed
813 </Text>
814 {' '}
815 {plusMinus(t, added, removed)}
816 </Text>
817 <Text color={C.soft}>
818 {edits.length} edit{edits.length === 1 ? '' : 's'} · {turnIds.length} turn{turnIds.length === 1 ? '' : 's'}
819 </Text>
820 </Box>
821 {section(t, 'FILES', width)}
822 {files.map((stat, index) => {
823 const bars = diffstat(stat.added, stat.removed, most, barWidth)
824 return (
825 <Box key={`cf-${index}`} flexDirection="column">
826 <Box flexDirection="row">
827 {cell(
828 t,
829 3,
830 <Text bold color={stat.isNew ? C.ok : C.warn}>
831 {stat.isNew ? ' A' : ' M'}
832 </Text>,
833 )}
834 {cell(t, pathWidth, <Button key={`cfile:${index}`} label={tail(stat.file, pathWidth - 1)} plain onPress={() => openFile($, stat.file)} />)}
835 {cell(t, 7, <Text color={C.ok}>+{stat.added}</Text>, true)}
836 {cell(t, 7, <Text color={stat.removed === 0 ? C.line : C.bad}>−{stat.removed}</Text>, true)}
837 <Text> </Text>
838 <Text>
839 <Text color={C.ok}>{bars.plus}</Text>
840 <Text color={C.bad}>{bars.minus}</Text>
841 </Text>
842 </Box>
843 {stat.symbols.length > 0 && (
844 <Text color={C.line} wrap="truncate-end">
845 {' '}ƒ {stat.symbols.join(' · ')}
846 </Text>
847 )}
848 </Box>
849 )
850 })}
851 {section(t, 'BY TURN', width, 'newest first')}
852 {turnIds.map(turnId => {
853 const inTurn = edits.filter(edit => edit.turnId === turnId)
854 const turnFiles = new Set(inTurn.map(edit => edit.file)).size
855 const prompt = turns.find(turn => turn.id === turnId)?.prompt ?? 'an earlier turn'
856 const stats = `${turnFiles} file${turnFiles === 1 ? '' : 's'}`
857 return (
858 <Box key={`ct-${turnId}`} flexDirection="row">
859 {cell(t, 5, <Button key={`cturn:${turnId}`} label={turnNumber(turns, turnId)} plain onPress={() => update($, changeEditAtom, () => inTurn[0]?.id ?? null)} />)}
860 {cell(
861 t,
862 Math.max(8, width - 5 - 22),
863 <Text italic color={C.soft} wrap="truncate-end">
864 “{prompt}”
865 </Text>,
866 )}
867 {cell(
868 t,
869 22,
870 <Text>
871 <Text color={C.soft}>{stats} </Text>
872 {plusMinus(
873 t,
874 inTurn.reduce((sum, edit) => sum + edit.added, 0),
875 inTurn.reduce((sum, edit) => sum + edit.removed, 0),
876 )}
877 </Text>,
878 true,
879 )}
880 </Box>
881 )
882 })}
883 {footer(t, [{ key: 'replay', hotkey: 'r', label: 'replay every edit', onPress: () => update($, changeEditAtom, () => edits[0]?.id ?? null) }], width)}
884 </Box>
885 )
886}
887
888function topBar(t: T, back: Action, title: string, right: RenderNode, width: number): RenderNode {
889 const { Box, Button, Text } = t
890 return (
891 <Box flexDirection="row" justifyContent="space-between">
892 <Box flexDirection="row" gap={1}>
893 <Button key={back.key} label={back.label} hotkey={back.hotkey} plain dimColor onPress={back.onPress} />
894 <Text bold color={C.text}>
895 {tail(title, Math.max(10, width - 30))}
896 </Text>
897 </Box>
898 {right}
899 </Box>
900 )
901}
902
903function diffBlock(t: T, diff: { text: string; isCut: boolean }, path: string, hint: string): RenderNode {
904 const { Box, Code, Text } = t
905 if (diff.text === '') return <Text dimColor> No line changes to show.</Text>
906 return (
907 <Box flexDirection="column">
908 <Code key={`diff-${path}`} source={diff.text} format="diff" path={path} wrap="truncate-end" />
909 {diff.isCut && <Text dimColor>… cut to fit: {hint}</Text>}
910 </Box>
911 )
912}
913
914function diffView($: EngineInterface, t: T, edits: readonly EditRecord[], edit: EditRecord, turns: readonly Turn[], width: number): RenderNode {
915 const { Box, Text } = t
916 const index = edits.findIndex(one => one.id === edit.id)
917 const turn = turns.find(one => one.id === edit.turnId)
918 const go = async (to: number): Promise<void> => {
919 const target = edits[to]
920 if (target !== undefined) await update($, changeEditAtom, () => target.id)
921 }
922 const diff = hunksText(edit.hunks)
923 return (
924 <Box flexDirection="column">
925 {topBar(
926 t,
927 { key: 'diff-back', hotkey: 'b', label: '‹ back', onPress: () => update($, changeEditAtom, () => null) },
928 edit.file,
929 <Text color={C.soft}>
930 edit {index + 1} of {edits.length}
931 </Text>,
932 width,
933 )}
934 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
935 <Text backgroundColor={C.chip} color={C.text}>
936 {` ${edit.kind} `}
937 </Text>
938 {plusMinus(t, edit.added, edit.removed)}
939 <Text color={C.soft} wrap="truncate-end">
940 · {turnNumber(turns, edit.turnId)} “{head(turn?.prompt ?? '', 40)}”
941 </Text>
942 </Box>
943 {edit.symbols.length > 0 && (
944 <Text color={C.warn} wrap="truncate-end">
945 ƒ {edit.symbols.join(' · ')}
946 </Text>
947 )}
948 <Box marginTop={1}>{diffBlock(t, { text: diff.text, isCut: diff.isCut || edit.isCut }, edit.file, 'open the whole file for the full change')}</Box>
949 {footer(
950 t,
951 [
952 ...(index > 0 ? [{ key: 'diff-prev', hotkey: 'p', label: '‹ previous', onPress: () => go(index - 1) }] : []),
953 ...(index < edits.length - 1 ? [{ key: 'diff-next', hotkey: 'n', label: 'next ›', onPress: () => go(index + 1) }] : []),
954 { key: 'diff-file', hotkey: 'f', label: 'whole file', onPress: () => openFile($, edit.file) },
955 { key: 'diff-preview', hotkey: 'v', label: 'preview', onPress: () => openPreview($, edit.file) },
956 ],
957 width,
958 )}
959 </Box>
960 )
961}
962
963function fileView($: EngineInterface, t: T, file: string, summary: FileSummary | null, edits: readonly EditRecord[], turns: readonly Turn[], width: number): RenderNode {
964 const { Box, Button, Text } = t
965 const mine = edits.filter(edit => edit.file === file)
966 const isNew = mine.some(edit => edit.kind === 'create')
967 return (
968 <Box flexDirection="column">
969 {topBar(
970 t,
971 { key: 'file-back', hotkey: 'b', label: '‹ back', onPress: () => update($, changeFileAtom, () => null) },
972 file,
973 summary?.status === 'ready' ? (
974 <Text>
975 <Text bold color={isNew ? C.ok : C.warn}>
976 {isNew ? 'A ' : 'M '}
977 </Text>
978 {plusMinus(t, summary.added, summary.removed)}
979 </Text>
980 ) : (
981 <Text> </Text>
982 ),
983 width,
984 )}
985 {summary?.status === 'loading' && <Text color={C.live}>Comparing with the file as it was…</Text>}
986 {summary?.status === 'error' && <Text color={C.bad}>{summary.error}</Text>}
987
988 {section(t, 'FUNCTIONS', width, summary?.status === 'ready' ? String(summary.symbols.length) : '')}
989 {summary?.status === 'ready' && summary.symbols.length === 0 && <Text dimColor> No function changed: imports, constants or top-level code.</Text>}
990 {summary?.symbols.map((change, index) => (
991 <Box key={`fs-${index}`} flexDirection="row">
992 {cell(
993 t,
994 5,
995 <Text color={CHANGE_MARK[change.change][1]}>
996 {' '}
997 {CHANGE_MARK[change.change][0]} {KIND_ICON_CODE[change.kind]}
998 </Text>,
999 )}
1000 {cell(
1001 t,
1002 Math.max(10, width - 5 - 18),
1003 change.change === 'removed' ? (
1004 <Text strikethrough dimColor>
1005 {change.name}
1006 </Text>
1007 ) : (
1008 <Button key={`fs-sym:${index}`} label={change.name} plain onPress={() => loadCalls($, change.name)} />
1009 ),
1010 )}
1011 {cell(
1012 t,
1013 18,
1014 <Text color={C.soft}>
1015 {change.change} · L{change.line}
1016 </Text>,
1017 true,
1018 )}
1019 </Box>
1020 ))}
1021
1022 {section(t, 'EDITS', width, `${mine.length}, oldest first`)}
1023 {mine.map((edit, index) => (
1024 <Box key={`fe-${edit.id}`} flexDirection="row">
1025 {cell(t, 5, <Button key={`fe:${index}`} label={`#${index + 1}`} plain onPress={() => update($, changeEditAtom, () => edit.id)} />)}
1026 {cell(t, 4, <Text color={C.accent}>{turnNumber(turns, edit.turnId)}</Text>)}
1027 {cell(t, 12, plusMinus(t, edit.added, edit.removed))}
1028 <Text color={C.soft} wrap="truncate-end">
1029 {edit.symbols.length > 0 ? `ƒ ${edit.symbols.join(' · ')}` : edit.kind}
1030 </Text>
1031 </Box>
1032 ))}
1033
1034 {section(t, 'DIFF', width, 'since Claude first touched it')}
1035 {summary?.status === 'ready' && <Box marginTop={1}>{diffBlock(t, { text: summary.diff, isCut: summary.isDiffCut }, file, 'step through the edits above for the rest')}</Box>}
1036 {footer(
1037 t,
1038 [
1039 { key: 'file-preview', hotkey: 'v', label: 'preview', onPress: () => openPreview($, file) },
1040 { key: 'file-components', hotkey: 'm', label: 'components', onPress: () => loadOutline($, file) },
1041 ],
1042 width,
1043 )}
1044 </Box>
1045 )
1046}
1047
1048// ── Preview ─────────────────────────────────────────────────────────────
1049
1050function csvTable(t: T, text: string, isTab: boolean, columns: number): RenderNode {
1051 const { Box, Text } = t
1052 const rows = parseCsv(text, MAX_CSV_ROWS + 1, isTab)
1053 const header = rows[0] ?? []
1054 const count = Math.max(1, Math.min(header.length, Math.floor(columns / 10)))
1055 const width = Math.max(6, Math.min(24, Math.floor((columns - count) / count)))
1056 const total = text.split('\n').filter(line => line.trim() !== '').length - 1
1057 return (
1058 <Box flexDirection="column">
1059 {rows.map((row, rowIndex) => (
1060 <Box key={`csv-${rowIndex}`} flexDirection="row">
1061 {row.slice(0, count).map((value, column) => (
1062 <Box key={`csv-${rowIndex}-${column}`} width={width + 1} flexShrink={0}>
1063 <Text bold={rowIndex === 0} color={rowIndex === 0 ? C.live : rowIndex % 2 === 0 ? C.soft : C.text}>
1064 {fit(value, width)}
1065 </Text>
1066 </Box>
1067 ))}
1068 </Box>
1069 ))}
1070 <Text dimColor>
1071 {Math.min(total, MAX_CSV_ROWS)} of {total} rows{header.length > count ? ` · ${count} of ${header.length} columns` : ''}
1072 </Text>
1073 </Box>
1074 )
1075}
1076
1077function previewBody(t: T, table: Record<string, unknown>, surface: string, preview: Preview, columns: number): RenderNode {
1078 const { Box, Text, Markdown, Code } = t
1079 // A surface without an element still lists it, as a fragment that draws nothing: choose by surface.
1080 const Image = surface === 'terminal' ? (table as { Image?: Elements['terminal']['Image'] }).Image : undefined
1081 const picture = (file: string, alt: string): RenderNode =>
1082 Image === undefined ? (
1083 <Text dimColor>Pictures draw in the terminal (kitty, Ghostty). Saved at {file}</Text>
1084 ) : (
1085 <Image key="preview-image" source={{ file, format: 'png', generation: preview.generation }} columns={Math.max(10, columns - 2)} rows={Math.max(6, Math.round((columns - 2) * 0.31))} alt={alt} />
1086 )
1087
1088 if (preview.kind === 'markdown') return <Markdown key="preview-md" text={preview.text.slice(0, MAX_MARKDOWN_CHARS)} />
1089 if (preview.kind === 'csv') return csvTable(t, preview.text, preview.path.toLowerCase().endsWith('.tsv'), columns)
1090 if (preview.kind === 'json') {
1091 let lines: ReturnType<typeof jsonTree>
1092 try {
1093 lines = jsonTree(parseJson(preview.text, preview.path))
1094 } catch (error) {
1095 return (
1096 <Box flexDirection="column">
1097 <Text color={C.bad}>Not valid JSON: {String(error)}</Text>
1098 <Code source={preview.text.slice(0, MAX_CODE_CHARS)} language="json" wrap="truncate-end" />
1099 </Box>
1100 )
1101 }
1102 const color = { object: C.accent, array: C.accent, string: C.ok, number: C.warn, boolean: C.live, null: C.line } as const
1103 return (
1104 <Box flexDirection="column">
1105 {lines.map((line, index) => (
1106 <Box key={`json-${index}`} flexDirection="row">
1107 <Text color={C.line}>{'│ '.repeat(line.depth)}</Text>
1108 <Text color={C.blue}>{line.key}</Text>
1109 <Text color={C.line}>: </Text>
1110 <Text color={color[line.kind]} wrap="truncate-end">
1111 {line.value}
1112 </Text>
1113 </Box>
1114 ))}
1115 </Box>
1116 )
1117 }
1118 if (preview.kind === 'html') {
1119 return (
1120 <Box flexDirection="column">
1121 {preview.image !== undefined && picture(preview.image, `Screenshot of ${preview.path}`)}
1122 {(preview.image === undefined || Image === undefined) && <Markdown key="preview-outline" text={htmlOutline(preview.text)} />}
1123 </Box>
1124 )
1125 }
1126 if (preview.kind === 'image') return preview.image === undefined ? <Text dimColor>{preview.note}</Text> : picture(preview.image, preview.path)
1127 if (preview.kind === 'svg') {
1128 const Svg = surface === 'desktop' || surface === 'mobile' ? (table as { Svg?: Elements['desktop']['Svg'] }).Svg : undefined
1129 return Svg === undefined ? <Code source={preview.text.slice(0, MAX_CODE_CHARS)} language="xml" wrap="truncate-end" /> : <Svg source={preview.text} alt={preview.path} />
1130 }
1131 const language = preview.kind === 'yaml' ? (preview.path.endsWith('.toml') ? 'toml' : 'yaml') : undefined
1132 return (
1133 <Box flexDirection="column">
1134 <Code source={preview.text.slice(0, MAX_CODE_CHARS)} path={preview.path} language={language} startLine={1} wrap="truncate-end" />
1135 {preview.text.length > MAX_CODE_CHARS && <Text dimColor>… showing the first {MAX_CODE_CHARS} characters</Text>}
1136 </Box>
1137 )
1138}
1139
1140/** Files worth a quick switch: the latest edited and created ones, newest first. */
1141function recentFiles(edits: readonly EditRecord[], artifacts: readonly Artifact[]): string[] {
1142 const seen = new Set<string>()
1143 const out: string[] = []
1144 for (const path of [...edits.map(edit => edit.file).reverse(), ...artifacts.map(one => one.path)]) {
1145 if (seen.has(path)) continue
1146 seen.add(path)
1147 out.push(path)
1148 }
1149 return out.slice(0, 6)
1150}
1151
1152function previewView(
1153 $: EngineInterface,
1154 t: T,
1155 table: Record<string, unknown>,
1156 surface: string,
1157 preview: Preview | null,
1158 isFollowing: boolean,
1159 recent: readonly string[],
1160 width: number,
1161): RenderNode {
1162 const { Box, Button, Text } = t
1163 const others = recent.filter(path => path !== preview?.path)
1164 const recentRow =
1165 others.length === 0 ? null : (
1166 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1167 <Text color={C.line}>recent</Text>
1168 {others.map((path, index) => (
1169 <Button key={`pv-recent:${index}`} label={path.split('/').pop() ?? path} plain dimColor onPress={() => openPreview($, path)} />
1170 ))}
1171 </Box>
1172 )
1173 if (preview === null) {
1174 return (
1175 <Box flexDirection="column">
1176 {empty(t, '◉', 'Nothing to preview yet', 'Files Claude edits open here as they change. Pick one in Changes or Artifacts, or run /wb preview <file>.')}
1177 {recentRow !== null && <Box marginTop={1}>{recentRow}</Box>}
1178 </Box>
1179 )
1180 }
1181 return (
1182 <Box flexDirection="column">
1183 <Box flexDirection="row" justifyContent="space-between">
1184 <Text bold color={C.text}>
1185 {tail(preview.path, Math.max(10, width - 28))}
1186 </Text>
1187 <Box flexDirection="row" columnGap={1}>
1188 <Text backgroundColor={C.chip} color={C.live}>
1189 {` ${preview.kind.toUpperCase()} `}
1190 </Text>
1191 <Text color={C.soft}>{bytes(preview.size)}</Text>
1192 <Text color={isFollowing ? C.ok : C.soft}>{isFollowing ? '● live' : '○ pinned'}</Text>
1193 </Box>
1194 </Box>
1195 {recentRow}
1196 {preview.note !== undefined && preview.kind !== 'image' && <Text color={C.warn}>{preview.note}</Text>}
1197 <Text color={C.line}>{'─'.repeat(width)}</Text>
1198 {preview.status === 'loading' && <Text color={C.live}>Rendering {preview.path}…</Text>}
1199 {preview.status === 'error' && <Text color={C.bad}>{preview.text}</Text>}
1200 {preview.status === 'ready' && previewBody(t, table, surface, preview, width)}hooks/calls.ts 94 lines1import type { Site } from '../types'
2import { isPython, symbolName } from './scan'
3import type { SymbolIndex } from './scan'
4
5const MAX_BODY_LINES = 400
6const MAX_CALLEES = 40
7const KEYWORDS = new Set([
8 'if', 'for', 'while', 'switch', 'return', 'catch', 'function', 'typeof', 'await', 'new', 'print', 'super',
9 'elif', 'with', 'assert', 'lambda', 'yield', 'not', 'and', 'or', 'in', 'is', 'def', 'class',
10])
11
12function indent(line: string): number {
13 return (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length
14}
15
16/** The lines of the definition starting at 1-based `line`: by indentation for Python, by braces otherwise. */
17export function bodyOf(text: string, line: number, path: string): string[] {
18 const lines = text.split('\n')
19 const start = line - 1
20 const head = lines[start]
21 if (head === undefined) return []
22
23 const body = [head]
24 if (isPython(path)) {
25 const base = indent(head)
26 for (let index = start + 1; index < lines.length && body.length < MAX_BODY_LINES; index += 1) {
27 const current = lines[index] ?? ''
28 if (current.trim() !== '' && indent(current) <= base) break
29 body.push(current)
30 }
31 while (body.length > 1 && (body[body.length - 1] ?? '').trim() === '') body.pop()
32 return body
33 }
34
35 let depth = (head.match(/\{/g) ?? []).length - (head.match(/\}/g) ?? []).length
36 let hasOpened = depth > 0
37 for (let index = start + 1; index < lines.length && body.length < MAX_BODY_LINES; index += 1) {
38 if (hasOpened && depth <= 0) break
39 const current = lines[index] ?? ''
40 body.push(current)
41 depth += (current.match(/\{/g) ?? []).length - (current.match(/\}/g) ?? []).length
42 if (depth > 0) hasOpened = true
43 }
44 return body
45}
46
47/** The function or class around 1-based `line`, found by walking up to the nearest definition that encloses it. */
48export function enclosingSymbol(text: string, line: number, path: string): string {
49 const lines = text.split('\n')
50 const target = lines[line - 1] ?? ''
51 for (let index = line - 2; index >= 0; index -= 1) {
52 const current = lines[index] ?? ''
53 const name = symbolName(current)
54 if (name === null) continue
55 if (isPython(path) ? indent(current) < indent(target) : bodyOf(text, index + 1, path).length >= line - index) {
56 return name
57 }
58 }
59 return '(module level)'
60}
61
62/** Functions a body calls, each marked known when the repo defines it. */
63export function calleesIn(body: readonly string[], self: string, index: SymbolIndex): Site[] {
64 const seen = new Set<string>()
65 const out: Site[] = []
66 for (const line of body.slice(1)) {
67 const code = line.replace(/(#|\/\/).*$/, '')
68 for (const match of code.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
69 const name = match[1] ?? ''
70 if (name === self || KEYWORDS.has(name) || seen.has(name)) continue
71 seen.add(name)
72 const definition = index.get(name)?.[0]
73 out.push(definition ?? { symbol: name, file: '', line: 0, isKnown: false })
74 if (out.length >= MAX_CALLEES) return sortKnownFirst(out)
75 }
76 }
77 return sortKnownFirst(out)
78}
79
80function sortKnownFirst(sites: Site[]): Site[] {
81 return sites.sort((a, b) => Number(b.isKnown) - Number(a.isKnown) || a.symbol.localeCompare(b.symbol))
82}
83
84/** True when a grep hit for `name(` is the definition itself rather than a call. */
85export function isDefinitionLine(text: string, name: string): boolean {
86 return symbolName(text) === name
87}
88
89/** The identifier under or around a selection: the last word that looks like a name. */
90export function symbolFromSelection(selection: string): string | null {
91 const words = selection.match(/[A-Za-z_$][\w$]*/g)
92 return words === null ? null : (words[words.length - 1] ?? null)
93}
94hooks/changes.ts 188 lines1import type { OutlineEntry, SymbolChange, SymbolKind } from '../types'
2import { bodyOf } from './calls'
3import { diffOps } from './diff'
4import { isPython, symbolName } from './scan'
5
6export type Range = readonly [number, number]
7
8export type DiffFile = {
9 path: string
10 status: 'modified' | 'added' | 'deleted'
11 added: number
12 removed: number
13 /** Changed lines in the current file, 1-based and inclusive. */
14 ranges: Range[]
15}
16
17/** Reads `git diff -U0` output into one entry per file. */
18export function parseDiff(diff: string): DiffFile[] {
19 const files: DiffFile[] = []
20 let current: DiffFile | null = null
21 for (const line of diff.split('\n')) {
22 const header = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
23 if (header !== null) {
24 current = { path: header[2] ?? '', status: 'modified', added: 0, removed: 0, ranges: [] }
25 files.push(current)
26 continue
27 }
28 if (current === null) continue
29 if (line.startsWith('new file mode')) current.status = 'added'
30 else if (line.startsWith('deleted file mode')) current.status = 'deleted'
31 else if (line.startsWith('+++') || line.startsWith('---')) continue
32 else if (line.startsWith('@@')) {
33 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
34 if (hunk !== null) {
35 const start = Number(hunk[1])
36 const count = hunk[2] === undefined ? 1 : Number(hunk[2])
37 current.ranges.push([Math.max(1, start), Math.max(1, start + Math.max(count, 1) - 1)])
38 }
39 } else if (line.startsWith('+')) current.added += 1
40 else if (line.startsWith('-')) current.removed += 1
41 }
42 return files
43}
44
45/**
46 * Compares two versions of a file line by line, as `git diff -U0` would: changed ranges in the new
47 * version, added and removed counts, and unified-diff text for the explanation prompt.
48 */
49export function lineDiff(path: string, before: string | null, after: string | null): DiffFile & { text: string } {
50 const split = (text: string): string[] => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
51 const status = before === null ? 'added' : after === null ? 'deleted' : 'modified'
52 type Op = { kind: ' ' | '-' | '+'; text: string }
53 const ops: Op[] = diffOps(split(before ?? ''), split(after ?? ''))
54
55 const ranges: Range[] = []
56 const text: string[] = [`--- a/${path}`, `+++ b/${path}`]
57 let oldLine = 1
58 let newLine = 1
59 let added = 0
60 let removed = 0
61 let index = 0
62 while (index < ops.length) {
63 if (ops[index]?.kind === ' ') {
64 oldLine += 1
65 newLine += 1
66 index += 1
67 continue
68 }
69 const hunk: Op[] = []
70 while (index < ops.length && ops[index]?.kind !== ' ') {
71 hunk.push(ops[index] as Op)
72 index += 1
73 }
74 const minus = hunk.filter(op => op.kind === '-').length
75 const plus = hunk.filter(op => op.kind === '+').length
76 text.push(`@@ -${oldLine},${minus} +${newLine},${plus} @@`, ...hunk.map(op => `${op.kind}${op.text}`))
77 ranges.push(plus > 0 ? [newLine, newLine + plus - 1] : [Math.max(1, newLine), Math.max(1, newLine)])
78 oldLine += minus
79 newLine += plus
80 added += plus
81 removed += minus
82 }
83 return { path, status, added, removed, ranges, text: ranges.length === 0 ? '' : text.join('\n') }
84}
85
86function indentOf(line: string): number {
87 return (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length
88}
89
90const JS_METHOD = /^\s+(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]*)?\{\s*$/
91const NOT_METHODS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'function', 'return'])
92
93/** The classes, functions and methods of a file, each with its line span and depth. */
94export function outlineOf(text: string, path: string): OutlineEntry[] {
95 const lines = text.split('\n')
96 const entries: OutlineEntry[] = []
97 const classes: { endLine: number; indent: number }[] = []
98
99 lines.forEach((line, index) => {
100 const number = index + 1
101 while (classes.length > 0 && (classes[classes.length - 1]?.endLine ?? 0) < number) classes.pop()
102
103 let name = symbolName(line)
104 let kind: SymbolKind | null = null
105 if (name !== null) {
106 kind = /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s/.test(line) ? 'class' : 'function'
107 } else if (!isPython(path) && classes.length > 0) {
108 const method = JS_METHOD.exec(line)
109 if (method?.[1] !== undefined && !NOT_METHODS.has(method[1])) {
110 name = method[1]
111 kind = 'method'
112 }
113 }
114 if (name === null || kind === null) return
115
116 const insideClass = classes.length > 0 && (!isPython(path) || indentOf(line) > (classes[classes.length - 1]?.indent ?? 0))
117 if (kind === 'function' && insideClass) kind = 'method'
118 const endLine = number + bodyOf(text, number, path).length - 1
119 entries.push({ name, kind, line: number, endLine, depth: insideClass ? classes.length : 0, isChanged: false, calls: [] })
120 if (kind === 'class') classes.push({ endLine, indent: indentOf(line) })
121 })
122 return entries
123}
124
125function overlaps(entry: OutlineEntry, ranges: readonly Range[]): boolean {
126 return ranges.some(([start, end]) => start <= entry.endLine && end >= entry.line)
127}
128
129/** Marks entries whose span a change touches; a class counts only for changes outside its methods. */
130export function markChanged(entries: readonly OutlineEntry[], ranges: readonly Range[]): OutlineEntry[] {
131 return entries.map(entry => {
132 if (!overlaps(entry, ranges)) return entry
133 if (entry.kind !== 'class') return { ...entry, isChanged: true }
134 const members = entries.filter(other => other !== entry && other.line > entry.line && other.endLine <= entry.endLine)
135 const outsideMembers = ranges.some(
136 ([start, end]) =>
137 start <= entry.endLine &&
138 end >= entry.line &&
139 !members.some(member => start >= member.line && end <= member.endLine),
140 )
141 return { ...entry, isChanged: outsideMembers }
142 })
143}
144
145/** What changed in one file at the level of its functions and classes. */
146export function symbolChanges(current: readonly OutlineEntry[], before: readonly OutlineEntry[], ranges: readonly Range[]): SymbolChange[] {
147 const beforeNames = new Set(before.map(entry => `${entry.kind}:${entry.name}`))
148 const currentNames = new Set(current.map(entry => `${entry.kind}:${entry.name}`))
149 const changes: SymbolChange[] = []
150 for (const entry of markChanged(current, ranges)) {
151 const key = `${entry.kind}:${entry.name}`
152 if (!beforeNames.has(key)) changes.push({ name: entry.name, kind: entry.kind, change: 'added', line: entry.line, callers: 0 })
153 else if (entry.isChanged) changes.push({ name: entry.name, kind: entry.kind, change: 'modified', line: entry.line, callers: 0 })
154 }
155 for (const entry of before) {
156 if (!currentNames.has(`${entry.kind}:${entry.name}`)) {
157 changes.push({ name: entry.name, kind: entry.kind, change: 'removed', line: entry.line, callers: 0 })
158 }
159 }
160 return changes
161}
162
163/**
164 * Counts call sites per name from `git grep -o` output, leaving out each name's own definition lines
165 * and longer identifiers that only end in a name (`refetch(` is not a call of `fetch`).
166 */
167export function countCallers(grepOutput: string, definitions: ReadonlySet<string>, names: ReadonlySet<string>): Map<string, number> {
168 const counts = new Map<string, number>()
169 for (const line of grepOutput.split('\n')) {
170 const match = /^(.+?):(\d+):([A-Za-z_$][\w$]*)/.exec(line)
171 if (match === null) continue
172 const [, file = '', number = '', name = ''] = match
173 if (!names.has(name) || definitions.has(`${file}:${number}:${name}`)) continue
174 counts.set(name, (counts.get(name) ?? 0) + 1)
175 }
176 return counts
177}
178
179export function escapeRegex(name: string): string {
180 return name.replace(/[$]/g, '\\$')
181}
182
183/** The changed entries, without a class whose change is already named by a changed member inside it. */
184export function innermostChanged(entries: readonly OutlineEntry[]): OutlineEntry[] {
185 const changed = entries.filter(entry => entry.isChanged)
186 return changed.filter(entry => !changed.some(other => other !== entry && other.depth > entry.depth && other.line >= entry.line && other.endLine <= entry.endLine))
187}
188hooks/diff.ts 143 lines1import type { EditHunk } from '../types'
2
3/** The most a Code element draws; a diff is cut at a hunk boundary below it, so it still parses. */
4export const MAX_DIFF_CHARS = 9500
5
6export type DiffText = { text: string; hunks: number; isCut: boolean }
7
8/** A line cut or padded to exactly `width` characters, tabs shown as two spaces. */
9export function fit(text: string, width: number): string {
10 const flat = text.replace(/\t/g, ' ')
11 return flat.length > width ? `${flat.slice(0, Math.max(0, width - 1))}…` : flat.padEnd(width, ' ')
12}
13
14/** Characters a Code element refuses: every control character but tab and newline. */
15function clean(line: string): string {
16 return line.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
17}
18
19/** Joins hunk texts until the next would pass `maxChars`. */
20function joinHunks(parts: readonly string[], maxChars: number): DiffText {
21 let text = ''
22 let count = 0
23 for (const part of parts) {
24 const next = text === '' ? part : `${text}\n${part}`
25 if (next.length > maxChars) return { text, hunks: count, isCut: true }
26 text = next
27 count += 1
28 }
29 return { text, hunks: count, isCut: false }
30}
31
32/** One recorded edit's hunks as unified-diff text, for `Code` with `format: 'diff'`. */
33export function hunksText(hunks: readonly EditHunk[], maxChars = MAX_DIFF_CHARS): DiffText {
34 return joinHunks(
35 hunks.map(hunk => [`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines.map(clean)].join('\n')),
36 maxChars,
37 )
38}
39
40type Op = { kind: ' ' | '-' | '+'; text: string }
41
42/** The LCS table above which a diff gives up line matching and marks the whole middle as replaced. */
43const MAX_DIFF_CELLS = 4_000_000
44
45/** Line operations turning `a` into `b`: a common head and tail, and a longest-common-subsequence middle. */
46export function diffOps(a: readonly string[], b: readonly string[]): Op[] {
47 let head = 0
48 while (head < a.length && head < b.length && a[head] === b[head]) head += 1
49 let tail = 0
50 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
51 const midA = a.slice(head, a.length - tail)
52 const midB = b.slice(head, b.length - tail)
53 const ops: Op[] = a.slice(0, head).map(text => ({ kind: ' ', text }))
54 if ((midA.length + 1) * (midB.length + 1) > MAX_DIFF_CELLS) {
55 ops.push(...midA.map(text => ({ kind: '-' as const, text })), ...midB.map(text => ({ kind: '+' as const, text })))
56 } else {
57 const width = midB.length + 1
58 const table = new Uint32Array((midA.length + 1) * width)
59 for (let i = midA.length - 1; i >= 0; i -= 1) {
60 for (let j = midB.length - 1; j >= 0; j -= 1) {
61 table[i * width + j] =
62 midA[i] === midB[j] ? (table[(i + 1) * width + j + 1] ?? 0) + 1 : Math.max(table[(i + 1) * width + j] ?? 0, table[i * width + j + 1] ?? 0)
63 }
64 }
65 let i = 0
66 let j = 0
67 while (i < midA.length || j < midB.length) {
68 if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
69 ops.push({ kind: ' ', text: midA[i] ?? '' })
70 i += 1
71 j += 1
72 } else if (j >= midB.length || (i < midA.length && (table[(i + 1) * width + j] ?? 0) >= (table[i * width + j + 1] ?? 0))) {
73 ops.push({ kind: '-', text: midA[i] ?? '' })
74 i += 1
75 } else {
76 ops.push({ kind: '+', text: midB[j] ?? '' })
77 j += 1
78 }
79 }
80 }
81 ops.push(...a.slice(a.length - tail).map(text => ({ kind: ' ' as const, text })))
82 return ops
83}
84
85function linesOf(text: string | null): string[] {
86 return text === null || text === '' ? [] : text.replace(/\n$/, '').split('\n')
87}
88
89/** The whole change from `before` to `after` as unified-diff text with `context` lines around each hunk. */
90export function unifiedDiff(before: string | null, after: string | null, context = 3, maxChars = MAX_DIFF_CHARS): DiffText {
91 const ops = diffOps(linesOf(before), linesOf(after))
92 const changed = ops.map((op, index) => (op.kind === ' ' ? -1 : index)).filter(index => index >= 0)
93 if (changed.length === 0) return { text: '', hunks: 0, isCut: false }
94
95 // Group changed lines whose context windows touch into one hunk each.
96 const spans: [number, number][] = []
97 for (const index of changed) {
98 const last = spans[spans.length - 1]
99 if (last !== undefined && index - context <= last[1] + context + 1) last[1] = index
100 else spans.push([index, index])
101 }
102
103 // Line numbers in the old and new file before each op.
104 const oldAt: number[] = []
105 const newAt: number[] = []
106 let oldLine = 1
107 let newLine = 1
108 for (const op of ops) {
109 oldAt.push(oldLine)
110 newAt.push(newLine)
111 if (op.kind !== '+') oldLine += 1
112 if (op.kind !== '-') newLine += 1
113 }
114
115 const parts = spans.map(([first, last]) => {
116 const from = Math.max(0, first - context)
117 const to = Math.min(ops.length - 1, last + context)
118 const slice = ops.slice(from, to + 1)
119 const oldCount = slice.filter(op => op.kind !== '+').length
120 const newCount = slice.filter(op => op.kind !== '-').length
121 const oldStart = oldCount === 0 ? Math.max(0, (oldAt[from] ?? 1) - 1) : (oldAt[from] ?? 1)
122 const newStart = newCount === 0 ? Math.max(0, (newAt[from] ?? 1) - 1) : (newAt[from] ?? 1)
123 return [`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, ...slice.map(op => clean(`${op.kind}${op.text}`))].join('\n')
124 })
125 return joinHunks(parts, maxChars)
126}
127
128/** The lines an edit really changed in the new file, leaving out the context lines its hunks carry. */
129export function changedRanges(hunks: readonly EditHunk[]): [number, number][] {
130 const ranges: [number, number][] = []
131 for (const hunk of hunks) {
132 let line = Math.max(1, hunk.newStart)
133 for (const text of hunk.lines) {
134 if (text.startsWith('+')) {
135 ranges.push([line, line])
136 line += 1
137 } else if (text.startsWith('-')) ranges.push([line, line])
138 else if (!text.startsWith('\\')) line += 1
139 }
140 }
141 return ranges
142}
143hooks/forecast.ts 71 lines1import type { Reading } from '../types'
2
3export const HISTORY_LENGTH = 12
4
5const BARS = '▁▂▃▄▅▆▇█'
6
7export type Sky = { icon: string; label: string; color: string; advice?: string }
8
9export function sky(percent: number): Sky {
10 if (percent < 50) return { icon: '☀', label: 'Clear', color: 'green' }
11 if (percent < 75) return { icon: '⛅', label: 'Cloudy', color: 'yellow' }
12 if (percent < 90) {
13 return { icon: '☂', label: 'Rain', color: 'yellow', advice: 'consider /compact soon' }
14 }
15 return { icon: '⚡', label: 'Storm', color: 'red', advice: 'run /compact now' }
16}
17
18export function sparkline(values: readonly number[]): string {
19 return values
20 .map(value => {
21 const level = Math.floor((Math.min(100, Math.max(0, value)) / 100) * BARS.length)
22 return BARS.charAt(Math.min(BARS.length - 1, level))
23 })
24 .join('')
25}
26
27/** Average growth per turn over the last few turns, ignoring drops (compaction). */
28export function growthPerTurn(history: readonly number[]): number | undefined {
29 const recent = history.slice(-6)
30 const deltas: number[] = []
31 for (let i = 1; i < recent.length; i += 1) {
32 const delta = (recent[i] ?? 0) - (recent[i - 1] ?? 0)
33 if (delta >= 0) deltas.push(delta)
34 }
35 if (deltas.length === 0) return undefined
36 return deltas.reduce((sum, delta) => sum + delta, 0) / deltas.length
37}
38
39export function turnsLeft(percent: number, perTurn: number | undefined): number | undefined {
40 if (perTurn === undefined || perTurn < 0.1) return undefined
41 return Math.max(0, Math.floor((100 - percent) / perTurn))
42}
43
44export function kilo(tokens: number): string {
45 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
46}
47
48export function outlook(history: readonly number[], reading: Reading): string {
49 const perTurn = growthPerTurn(history)
50 const left = turnsLeft(reading.percent, perTurn)
51 if (perTurn === undefined) return 'steady'
52 const rate = `+${perTurn.toFixed(1)}%/turn`
53 return left === undefined ? rate : `${rate} · ~${left} turns to full`
54}
55
56export function forecastText(history: readonly number[], reading: Reading | null, limitLines: readonly string[]): string {
57 const lines = ['Usage Forecast']
58 if (reading === null) {
59 lines.push('Context: no reading yet. It appears after the first reply.')
60 } else {
61 const now = sky(reading.percent)
62 lines.push(
63 `${now.icon} ${now.label}: context ${reading.percent}% full (${kilo(reading.tokens)}/${kilo(reading.window)} tokens)`,
64 `Last ${history.length} turns: ${sparkline(history) || '-'} ${outlook(history, reading)}`,
65 )
66 if (now.advice !== undefined) lines.push(`Advice: ${now.advice}`)
67 }
68 lines.push(...(limitLines.length > 0 ? limitLines : ['Plan limits: no reading yet (they appear after a reply on a Claude subscription).']))
69 return lines.join('\n')
70}
71hooks/files.ts 134 lines1import type { ArtifactKind, PreviewKind } from '../types'
2
3const EXT = (path: string): string => (/\.([^./]+)$/.exec(path.toLowerCase())?.[1] ?? '')
4
5export function previewKind(path: string): PreviewKind {
6 const ext = EXT(path)
7 if (['md', 'markdown', 'mdx'].includes(ext)) return 'markdown'
8 if (['json', 'jsonl', 'geojson', 'ipynb'].includes(ext)) return 'json'
9 if (['yaml', 'yml', 'toml'].includes(ext)) return 'yaml'
10 if (['csv', 'tsv'].includes(ext)) return 'csv'
11 if (['html', 'htm'].includes(ext)) return 'html'
12 if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) return 'image'
13 if (ext === 'svg') return 'svg'
14 if (['txt', 'log', 'rst', ''].includes(ext)) return 'text'
15 return 'code'
16}
17
18export function artifactKind(path: string): ArtifactKind {
19 const ext = EXT(path)
20 if (['md', 'markdown', 'txt', 'pdf', 'docx', 'doc', 'rst', 'pptx', 'rtf'].includes(ext)) return 'doc'
21 if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image'
22 if (['csv', 'tsv', 'json', 'jsonl', 'xlsx', 'xls', 'parquet', 'yaml', 'yml', 'sql', 'db', 'sqlite'].includes(ext)) return 'data'
23 if (['html', 'htm', 'css'].includes(ext)) return 'web'
24 if (['py', 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'go', 'rs', 'java', 'rb', 'sh', 'kt', 'swift', 'c', 'cpp', 'h'].includes(ext)) return 'code'
25 return 'other'
26}
27
28export const KIND_ICON: Record<ArtifactKind, string> = { doc: '📄', image: '🖼', data: '📊', web: '🌐', code: '⌨', other: '▫' }
29export const KIND_LABEL: Record<ArtifactKind, string> = { doc: 'Documents', image: 'Images', data: 'Data', web: 'Web', code: 'Code', other: 'Other' }
30
31export function bytes(size: number): string {
32 if (size < 1024) return `${size} B`
33 if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10 * 1024 ? 1 : 0)} KB`
34 return `${(size / 1024 / 1024).toFixed(1)} MB`
35}
36
37export function ago(ms: number): string {
38 const seconds = Math.max(0, Math.round(ms / 1000))
39 if (seconds < 60) return `${seconds}s ago`
40 if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`
41 return `${Math.round(seconds / 3600)}h ago`
42}
43
44/** Splits CSV or TSV text into rows of cells, honouring double quotes; at most `maxRows` rows. */
45export function parseCsv(text: string, maxRows: number, isTab = false): string[][] {
46 const separator = isTab ? '\t' : ','
47 const rows: string[][] = []
48 let row: string[] = []
49 let cell = ''
50 let isQuoted = false
51 for (let index = 0; index < text.length && rows.length < maxRows; index += 1) {
52 const char = text.charAt(index)
53 if (isQuoted) {
54 if (char === '"' && text.charAt(index + 1) === '"') {
55 cell += '"'
56 index += 1
57 } else if (char === '"') isQuoted = false
58 else cell += char
59 } else if (char === '"') isQuoted = true
60 else if (char === separator) {
61 row.push(cell)
62 cell = ''
63 } else if (char === '\n' || char === '\r') {
64 if (char === '\r' && text.charAt(index + 1) === '\n') index += 1
65 row.push(cell)
66 rows.push(row)
67 row = []
68 cell = ''
69 } else cell += char
70 }
71 if ((cell !== '' || row.length > 0) && rows.length < maxRows) rows.push([...row, cell])
72 return rows
73}
74
75export type TreeLine = { depth: number; key: string; value: string; kind: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' }
76
77/** A JSON value as indented lines, cut at `maxDepth` levels, `maxItems` children per node and `maxLines` overall. */
78export function jsonTree(value: unknown, maxDepth = 5, maxItems = 25, maxLines = 300): TreeLine[] {
79 const lines: TreeLine[] = []
80 const walk = (node: unknown, key: string, depth: number): void => {
81 if (lines.length >= maxLines) return
82 if (Array.isArray(node)) {
83 lines.push({ depth, key, value: `[${node.length}]`, kind: 'array' })
84 if (depth >= maxDepth) return
85 node.slice(0, maxItems).forEach((child, index) => walk(child, String(index), depth + 1))
86 if (node.length > maxItems) lines.push({ depth: depth + 1, key: '…', value: `${node.length - maxItems} more`, kind: 'null' })
87 } else if (node !== null && typeof node === 'object') {
88 const entries = Object.entries(node)
89 lines.push({ depth, key, value: `{${entries.length}}`, kind: 'object' })
90 if (depth >= maxDepth) return
91 entries.slice(0, maxItems).forEach(([childKey, child]) => walk(child, childKey, depth + 1))
92 if (entries.length > maxItems) lines.push({ depth: depth + 1, key: '…', value: `${entries.length - maxItems} more`, kind: 'null' })
93 } else if (typeof node === 'string') {
94 lines.push({ depth, key, value: JSON.stringify(node.length > 120 ? `${node.slice(0, 120)}…` : node), kind: 'string' })
95 } else if (typeof node === 'number') {
96 lines.push({ depth, key, value: String(node), kind: 'number' })
97 } else if (typeof node === 'boolean') {
98 lines.push({ depth, key, value: String(node), kind: 'boolean' })
99 } else {
100 lines.push({ depth, key, value: 'null', kind: 'null' })
101 }
102 }
103 walk(value, '(root)', 0)
104 return lines
105}
106
107/** Parses JSON, or JSON Lines one record per line. */
108export function parseJson(text: string, path: string): unknown {
109 if (path.toLowerCase().endsWith('.jsonl')) {
110 return text
111 .split('\n')
112 .filter(line => line.trim() !== '')
113 .slice(0, 200)
114 .map(line => JSON.parse(line) as unknown)
115 }
116 return JSON.parse(text) as unknown
117}
118
119/** For HTML without a screenshot: the title, headings and links, as a readable outline. */
120export function htmlOutline(html: string): string {
121 const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim()
122 const headings = [...html.matchAll(/<h([1-4])[^>]*>([\s\S]*?)<\/h\1>/gi)].map(match => {
123 const level = Number(match[1])
124 const text = (match[2] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
125 return `${'#'.repeat(level + 1)} ${text}`
126 })
127 const links = [...html.matchAll(/<a\s[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)]
128 .slice(0, 15)
129 .map(match => `- ${(match[2] ?? '').replace(/<[^>]+>/g, '').trim() || match[1]} → ${match[1]}`)
130 return [`# ${title ?? '(untitled page)'}`, ...headings.slice(0, 40), links.length > 0 ? '\n**Links**' : '', ...links]
131 .filter(line => line !== '')
132 .join('\n')
133}
134hooks/limits.ts 86 lines1import type { Limit } from '../types'
2
3const HOUR = 3_600_000
4const DAY = 24 * HOUR
5const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
6const ORDER = ['five_hour', 'seven_day']
7const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
8
9export function limitLabel(kind: string): string {
10 if (kind === 'five_hour') return '5-hour'
11 if (kind === 'seven_day') return 'Weekly'
12 if (kind === 'spend_limit') return 'Spend'
13 return kind.replace(/_/g, ' ')
14}
15
16/** The plan windows in a fixed order: 5-hour first, then weekly, then anything else. */
17export function sortLimits(limits: readonly Limit[]): Limit[] {
18 const rank = (kind: string): number => (ORDER.includes(kind) ? ORDER.indexOf(kind) : ORDER.length)
19 return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind) || a.kind.localeCompare(b.kind))
20}
21
22export function usageColor(percent: number): string {
23 if (percent >= 90) return 'red'
24 if (percent >= 70) return 'yellow'
25 return 'green'
26}
27
28/** A bar of `width` cells: filled blocks for the used share, light shade for the rest. */
29export function meter(percent: number, width = 12): { used: string; left: string } {
30 const filled = Math.max(0, Math.min(width, Math.round((Math.min(100, percent) / 100) * width)))
31 return { used: '█'.repeat(filled), left: '░'.repeat(width - filled) }
32}
33
34/** "1h 12m", "3d 4h", "8m"; "now" once the time has passed. */
35export function duration(ms: number): string {
36 if (ms <= 0) return 'now'
37 const minutes = Math.ceil(ms / 60_000)
38 if (minutes < 60) return `${minutes}m`
39 const hours = Math.floor(minutes / 60)
40 if (hours < 24) return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
41 const days = Math.floor(hours / 24)
42 return hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`
43}
44
45/** The reset as a wall-clock time: "15:40" today, "Mon 09:00" on another day. */
46export function clockTime(resetMs: number, nowMs: number): string {
47 const reset = new Date(resetMs)
48 const time = `${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`
49 return new Date(nowMs).toDateString() === reset.toDateString() ? time : `${WEEKDAYS[reset.getDay()] ?? ''} ${time}`
50}
51
52export type LimitView = {
53 label: string
54 percent: number
55 color: string
56 resetIn?: string
57 resetAt?: string
58 /** Set when the window will run out before it resets at the current pace: how long until it does. */
59 runsOutIn?: string
60}
61
62/** Everything the band shows for one limit, worked out at time `now`. */
63export function viewOf(limit: Limit, now: number): LimitView {
64 const view: LimitView = { label: limitLabel(limit.kind), percent: limit.percentUsed, color: usageColor(limit.percentUsed) }
65 const reset = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
66 if (Number.isNaN(reset)) return view
67
68 view.resetIn = duration(reset - now)
69 view.resetAt = clockTime(reset, now)
70
71 const windowMs = WINDOW_MS[limit.kind]
72 if (windowMs === undefined || limit.percentUsed <= 0 || limit.percentUsed >= 100) return view
73 const elapsed = now - (reset - windowMs)
74 if (elapsed < 5 * 60_000) return view
75 const rate = limit.percentUsed / elapsed
76 const untilFull = (100 - limit.percentUsed) / rate
77 if (untilFull < reset - now) view.runsOutIn = duration(untilFull)
78 return view
79}
80
81export function limitText(view: LimitView): string {
82 const reset = view.resetIn === undefined ? '' : `, resets in ${view.resetIn} (${view.resetAt})`
83 const pace = view.runsOutIn === undefined ? '' : `. At this pace it runs out in ${view.runsOutIn}`
84 return `${view.label} limit: ${view.percent}% used${reset}${pace}`
85}
86hooks/scan.ts 300 lines1import type { Edge, Graph, Group, Site } from '../types'
2
3export const SOURCE_GLOBS = ['*.py', '*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs']
4
5/** Lines `git grep -E` matches as possible imports, in POSIX classes so every git build reads them. */
6export const IMPORT_PATTERN =
7 '^[[:space:]]*(import|from)[[:space:]]|require\\(|^[[:space:]]*export[[:space:]].*[[:space:]]from[[:space:]]'
8
9/** Lines `git grep -E` matches as possible definitions. */
10export const SYMBOL_PATTERN =
11 '^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_]|^[[:space:]]*class[[:space:]]+[A-Za-z_]|function[[:space:]]*\\*?[[:space:]]+[A-Za-z_$]|^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?(\\(|function|[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=>)'
12
13/** The same two patterns as JavaScript expressions, for folders searched without git. */
14export const IMPORT_RE = /^\s*(import|from)\s|require\(|^\s*export\s.*\sfrom\s/
15export const SYMBOL_RE =
16 /^\s*(async\s+)?def\s+[A-Za-z_]|^\s*class\s+[A-Za-z_]|function\s*\*?\s+[A-Za-z_$]|^\s*(export\s+)?(const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(async\s*)?(\(|function|[A-Za-z_$][\w$]*\s*=>)/
17
18export const SOURCE_EXTENSIONS = ['.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
19
20/** Folders a walk without git never enters: dependencies, environments, caches and build output. */
21export const SKIPPED_DIRS = new Set([
22 'node_modules', 'venv', '.venv', 'env', '.env', '__pycache__', 'dist', 'build', 'out', 'target', 'coverage',
23 'site-packages', '.git', '.hg', '.svn', '.next', '.nuxt', '.tox', '.mypy_cache', '.pytest_cache', '.ruff_cache',
24 '.idea', '.vscode', '.cache', '.turbo', 'vendor',
25])
26
27export function isSource(name: string): boolean {
28 return SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) && !name.endsWith('.d.ts') && !name.endsWith('.min.js')
29}
30
31/** What `git grep -n -E` prints, produced from texts held in memory. */
32export function grepTexts(texts: ReadonlyMap<string, string>, pattern: RegExp): string {
33 const out: string[] = []
34 for (const [path, text] of texts) {
35 text.split('\n').forEach((line, index) => {
36 if (pattern.test(line)) out.push(`${path}:${index + 1}:${line}`)
37 })
38 }
39 return out.join('\n')
40}
41
42/** What `git grep -n -o -E` prints: each match on its own line. */
43export function grepMatches(texts: ReadonlyMap<string, string>, pattern: RegExp): string {
44 const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)
45 const out: string[] = []
46 for (const [path, text] of texts) {
47 text.split('\n').forEach((line, index) => {
48 for (const match of line.matchAll(global)) out.push(`${path}:${index + 1}:${match[0]}`)
49 })
50 }
51 return out.join('\n')
52}
53
54/** What `git grep -c ''` prints: the line count of each file. */
55export function countTexts(texts: ReadonlyMap<string, string>): string {
56 return [...texts].map(([path, text]) => `${path}:${text.split('\n').length - (text.endsWith('\n') ? 1 : 0)}`).join('\n')
57}
58
59export const MAX_FILES = 4000
60const MAX_GROUP_PATHS = 60
61const MAX_GROUPS = 80
62const MAX_EDGES = 1000
63const GROUP_DEPTH = 3
64const JS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
65
66export type FileImports = Map<string, Set<string>>
67export type SymbolIndex = Map<string, Site[]>
68
69/** Everything a scan learned: the map for the pane, and the indexes later lookups use. */
70export type Scan = { graph: Graph; imports: FileImports; symbols: SymbolIndex }
71
72export function isPython(path: string): boolean {
73 return path.endsWith('.py')
74}
75
76/** The folder a file is drawn under: its directory, cut to GROUP_DEPTH levels. */
77export function groupOf(path: string): string {
78 const parts = path.split('/').slice(0, -1)
79 return parts.length === 0 ? '(root)' : parts.slice(0, GROUP_DEPTH).join('/')
80}
81
82/** Splits one `git grep -n` line into file, line number and text. */
83export function grepLine(line: string): { file: string; line: number; text: string } | null {
84 const match = /^(.+?):(\d+):(.*)$/.exec(line)
85 if (match === null) return null
86 return { file: match[1] ?? '', line: Number(match[2]), text: match[3] ?? '' }
87}
88
89function normalize(parts: readonly string[]): string {
90 const out: string[] = []
91 for (const part of parts) {
92 if (part === '' || part === '.') continue
93 if (part === '..') out.pop()
94 else out.push(part)
95 }
96 return out.join('/')
97}
98
99function dirOf(path: string): string[] {
100 return path.split('/').slice(0, -1)
101}
102
103/** Finds the repo file a dotted Python module names, also under a prefix such as src/. */
104function pythonFile(dotted: string, base: readonly string[], files: ReadonlySet<string>, bySuffix: Map<string, string>): string | null {
105 const stem = normalize([...base, ...dotted.split('.')])
106 for (const candidate of [`${stem}.py`, `${stem}/__init__.py`]) {
107 if (files.has(candidate)) return candidate
108 if (base.length === 0) {
109 const found = bySuffix.get(candidate)
110 if (found !== undefined) return found
111 }
112 }
113 return null
114}
115
116/** The repo files one Python import line brings in. */
117export function resolvePython(from: string, text: string, files: ReadonlySet<string>, bySuffix: Map<string, string>): string[] {
118 const out: string[] = []
119 const fromImport = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+)$/.exec(text)
120 if (fromImport !== null) {
121 const dots = (fromImport[1] ?? '').length
122 const module = fromImport[2] ?? ''
123 const base = dots === 0 ? [] : dirOf(from).slice(0, Math.max(0, dirOf(from).length - (dots - 1)))
124 const whole = module === '' ? null : pythonFile(module, base, files, bySuffix)
125 if (whole !== null) out.push(whole)
126 const names = (fromImport[3] ?? '').replace(/[()]/g, '').split(',')
127 for (const name of names) {
128 const bare = name.trim().split(/\s+/)[0] ?? ''
129 if (!/^\w+$/.test(bare)) continue
130 const sub = pythonFile(module === '' ? bare : `${module}.${bare}`, base, files, bySuffix)
131 if (sub !== null) out.push(sub)
132 }
133 return out
134 }
135 const plain = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/.exec(text)
136 if (plain !== null) {
137 for (const module of (plain[1] ?? '').split(',')) {
138 const found = pythonFile(module.trim(), [], files, bySuffix)
139 if (found !== null) out.push(found)
140 }
141 }
142 return out
143}
144
145/** The repo files one JS or TS import line brings in; package imports are left out. */
146export function resolveJs(from: string, text: string, files: ReadonlySet<string>): string[] {
147 const out: string[] = []
148 const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\(\s*)['"]([^'"]+)['"]/g
149 for (const match of text.matchAll(pattern)) {
150 const spec = match[1] ?? ''
151 if (!spec.startsWith('.')) continue
152 const stem = normalize([...dirOf(from), ...spec.split('/')])
153 const candidates = [
154 stem,
155 ...JS_EXTENSIONS.map(ext => `${stem}${ext}`),
156 ...JS_EXTENSIONS.map(ext => `${stem}/index${ext}`),
157 stem.replace(/\.js$/, '.ts'),
158 stem.replace(/\.js$/, '.tsx'),
159 ]
160 const found = candidates.find(candidate => files.has(candidate))
161 if (found !== undefined) out.push(found)
162 }
163 return out
164}
165
166/** The name a definition line declares, or null. */
167export function symbolName(text: string): string | null {
168 const patterns = [
169 /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/,
170 /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
171 /function\s*\*?\s+([A-Za-z_$][\w$]*)/,
172 /^\s*(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/,
173 ]
174 for (const pattern of patterns) {
175 const match = pattern.exec(text)
176 if (match?.[1] !== undefined) return match[1]
177 }
178 return null
179}
180
181/** Height of each folder in the dependency order, cycles broken where they close. */
182export function levels(ids: readonly string[], edges: readonly Edge[]): Map<string, number> {
183 const out = new Map<string, string[]>()
184 for (const edge of edges) out.set(edge.from, [...(out.get(edge.from) ?? []), edge.to])
185 const memo = new Map<string, number>()
186 const visiting = new Set<string>()
187 const level = (id: string): number => {
188 const known = memo.get(id)
189 if (known !== undefined) return known
190 if (visiting.has(id)) return 0
191 visiting.add(id)
192 const deps = out.get(id) ?? []
193 const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map(level))
194 visiting.delete(id)
195 memo.set(id, value)
196 return value
197 }
198 return new Map(ids.map(id => [id, level(id)]))
199}
200
201/** Builds the map and indexes from the four git outputs a scan collects, or their in-memory equivalents. */
202export function buildScan(root: string, fileList: string, lineCounts: string, importLines: string, symbolLines: string, isGit = true): Scan {
203 const fileNames = fileList.split('\n').filter(Boolean)
204 const isTruncated = fileNames.length > MAX_FILES
205 const files = new Set(fileNames.slice(0, MAX_FILES))
206 const bySuffix = new Map<string, string>()
207 for (const file of files) {
208 const parts = file.split('/')
209 for (let start = 1; start < parts.length; start += 1) {
210 const suffix = parts.slice(start).join('/')
211 if (!bySuffix.has(suffix)) bySuffix.set(suffix, file)
212 }
213 }
214
215 const lines = new Map<string, number>()
216 for (const row of lineCounts.split('\n')) {
217 const match = /^(.+):(\d+)$/.exec(row)
218 if (match !== null && files.has(match[1] ?? '')) lines.set(match[1] ?? '', Number(match[2]))
219 }
220
221 const imports: FileImports = new Map()
222 for (const row of importLines.split('\n')) {
223 const hit = grepLine(row)
224 if (hit === null || !files.has(hit.file)) continue
225 const targets = isPython(hit.file) ? resolvePython(hit.file, hit.text, files, bySuffix) : resolveJs(hit.file, hit.text, files)
226 for (const target of targets) {
227 if (target === hit.file) continue
228 const set = imports.get(hit.file) ?? new Set<string>()
229 set.add(target)
230 imports.set(hit.file, set)
231 }
232 }
233
234 const symbols: SymbolIndex = new Map()
235 const symbolCount = new Map<string, number>()
236 for (const row of symbolLines.split('\n')) {
237 const hit = grepLine(row)
238 if (hit === null || !files.has(hit.file)) continue
239 const name = symbolName(hit.text)
240 if (name === null) continue
241 symbols.set(name, [...(symbols.get(name) ?? []), { symbol: name, file: hit.file, line: hit.line, isKnown: true }])
242 symbolCount.set(groupOf(hit.file), (symbolCount.get(groupOf(hit.file)) ?? 0) + 1)
243 }
244
245 const groupFiles = new Map<string, string[]>()
246 for (const file of files) groupFiles.set(groupOf(file), [...(groupFiles.get(groupOf(file)) ?? []), file])
247
248 const edgeCount = new Map<string, number>()
249 for (const [from, targets] of imports) {
250 for (const to of targets) {
251 const key = `${groupOf(from)}\u0000${groupOf(to)}`
252 if (groupOf(from) !== groupOf(to)) edgeCount.set(key, (edgeCount.get(key) ?? 0) + 1)
253 }
254 }
255 const edges: Edge[] = [...edgeCount]
256 .map(([key, count]) => {
257 const [from = '', to = ''] = key.split('\u0000')
258 return { from, to, count }
259 })
260 .sort((a, b) => b.count - a.count)
261 .slice(0, MAX_EDGES)
262
263 const byLines = [...groupFiles.keys()].sort(
264 (a, b) => (groupFiles.get(b)?.length ?? 0) - (groupFiles.get(a)?.length ?? 0),
265 )
266 const kept = new Set(byLines.slice(0, MAX_GROUPS))
267 const keptEdges = edges.filter(edge => kept.has(edge.from) && kept.has(edge.to))
268 const heights = levels([...kept], keptEdges)
269 const groups: Group[] = [...kept]
270 .map(id => ({
271 id,
272 files: groupFiles.get(id)?.length ?? 0,
273 lines: (groupFiles.get(id) ?? []).reduce((sum, file) => sum + (lines.get(file) ?? 0), 0),
274 symbols: symbolCount.get(id) ?? 0,
275 level: heights.get(id) ?? 0,
276 paths: [...(groupFiles.get(id) ?? [])].sort().slice(0, MAX_GROUP_PATHS),
277 }))
278 .sort((a, b) => b.level - a.level || a.id.localeCompare(b.id))
279
280 return {
281 graph: { root, isGit, files: files.size, groups, edges: keptEdges, isTruncated: isTruncated || kept.size < groupFiles.size },
282 imports,
283 symbols,
284 }
285}
286
287/** Files outside `changed` that import any file in it. */
288export function dependentsOf(changed: ReadonlySet<string>, imports: FileImports): string[] {
289 const out: string[] = []
290 for (const [from, targets] of imports) {
291 if (changed.has(from)) continue
292 if ([...targets].some(target => changed.has(target))) out.push(from)
293 }
294 return out.sort()
295}
296
297export function shortCount(value: number): string {
298 return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value)
299}
300hooks/theme.ts 81 lines1/**
2 * One palette for every view: soft, saturated tones that read on dark terminals and in the desktop app.
3 * ok/bad/warn carry meaning everywhere (added/passing, removed/failing, modified/warning).
4 */
5export const C = {
6 ok: '#34D399',
7 bad: '#F87171',
8 warn: '#FBBF24',
9 live: '#22D3EE',
10 accent: '#A78BFA',
11 blue: '#60A5FA',
12 pink: '#F472B6',
13 line: '#475569',
14 soft: '#94A3B8',
15 text: '#E2E8F0',
16 /** Dark text on a filled pill. */
17 ink: '#1E1B2E',
18 /** The fill behind a quiet chip. */
19 chip: '#334155',
20} as const
21
22const NAMED: Record<string, string> = {
23 green: C.ok,
24 red: C.bad,
25 yellow: C.warn,
26 cyan: C.live,
27 magenta: C.accent,
28 blue: C.blue,
29 gray: C.line,
30}
31
32/** The palette's tone for a colour name the shared helpers use ('green', 'red', ...); other values pass through. */
33export function hue(name: string | undefined): string | undefined {
34 return name === undefined ? undefined : (NAMED[name] ?? name)
35}
36
37/** A compact gauge of `width` cells: ▰ for the used share, ▱ for the rest. */
38export function pill(percent: number, width: number): { used: string; left: string } {
39 const filled = Math.max(0, Math.min(width, Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)))
40 return { used: '▰'.repeat(filled), left: '▱'.repeat(width - filled) }
41}
42
43/** A git-style change bar: green cells for added lines and red for removed, scaled so `most` fills `width`. */
44export function diffstat(added: number, removed: number, most: number, width: number): { plus: string; minus: string } {
45 const total = added + removed
46 if (total === 0 || most <= 0) return { plus: '', minus: '' }
47 const cells = Math.max(1, Math.round((Math.min(total, most) / most) * width))
48 const plus = added === 0 ? 0 : removed === 0 ? cells : Math.max(1, Math.round((added / total) * cells))
49 return { plus: '▮'.repeat(plus), minus: '▮'.repeat(Math.max(0, cells - plus)) }
50}
51
52/** Cuts text to `width` cells, keeping the end: a path keeps its file name. */
53export function tail(text: string, width: number): string {
54 if (width <= 1) return text.slice(-Math.max(0, width))
55 return text.length <= width ? text : `…${text.slice(text.length - width + 1)}`
56}
57
58/** Cuts text to `width` cells, keeping the start. */
59export function head(text: string, width: number): string {
60 if (width <= 1) return text.slice(0, Math.max(0, width))
61 return text.length <= width ? text : `${text.slice(0, width - 1)}…`
62}
63
64/** One line of a prompt, without runs of white space. */
65export function oneLine(text: string): string {
66 return text.replace(/\s+/g, ' ').trim()
67}
68
69/** True when a drawn tree holds any visible text: whether another mod drew something in the band. */
70export function hasContent(node: unknown): boolean {
71 if (typeof node === 'string') return node.trim() !== ''
72 if (typeof node === 'number') return true
73 if (Array.isArray(node)) return node.some(hasContent)
74 if (node !== null && typeof node === 'object') {
75 const element = node as { children?: unknown; props?: { label?: unknown } }
76 if (typeof element.props?.label === 'string' && element.props.label.trim() !== '') return true
77 return hasContent(element.children)
78 }
79 return false
80}
81hooks/trace.ts 142 lines1import type { Lane, Step, StepStatus, Turn } from '../types'
2
3export const MAX_TURNS = 5
4const MAX_STEPS = 200
5const MAX_OUTPUT = 240
6
7function basename(path: string): string {
8 return path.split('/').filter(Boolean).pop() ?? path
9}
10
11function firstString(input: Record<string, unknown>): string {
12 const found = Object.values(input).find(value => typeof value === 'string')
13 return typeof found === 'string' ? found : ''
14}
15
16/** One short line saying what a tool call is about. */
17export function summarize(tool: string, input: Record<string, unknown>): string {
18 const get = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
19 switch (tool) {
20 case 'Bash':
21 return get('command')
22 case 'Read':
23 case 'Edit':
24 case 'Write':
25 return basename(get('file_path'))
26 case 'NotebookEdit':
27 return basename(get('notebook_path'))
28 case 'Grep':
29 case 'Glob':
30 return get('pattern')
31 case 'WebFetch':
32 return get('url')
33 case 'WebSearch':
34 return get('query')
35 case 'Agent':
36 return [get('subagent_type'), get('description')].filter(Boolean).join(': ')
37 case 'Skill':
38 return get('skill')
39 default:
40 return firstString(input)
41 }
42}
43
44export function preview(text: string | undefined): string {
45 const lines = (text ?? '').trim().split('\n').slice(0, 3).join('\n')
46 return lines.length > MAX_OUTPUT ? `${lines.slice(0, MAX_OUTPUT)}…` : lines
47}
48
49export function statusOf(ran: { deny?: string; isError?: true }): StepStatus {
50 if (ran.deny !== undefined) return 'denied'
51 return ran.isError === true ? 'error' : 'ok'
52}
53
54/** Applies a change to the newest turn, creating one when a tool runs before any turn started. */
55export function onNewest(turns: readonly Turn[], now: number, change: (turn: Turn) => Turn): Turn[] {
56 const newest = turns[turns.length - 1] ?? {
57 id: 'earlier',
58 prompt: '(before this view opened)',
59 startedAt: now,
60 status: 'running',
61 steps: [],
62 lanes: [{ id: 'main', label: 'Main' }],
63 }
64 return [...turns.slice(0, -1), change(newest)]
65}
66
67export function withStep(turn: Turn, step: Step, laneLabel: string): Turn {
68 const hasLane = turn.lanes.some(lane => lane.id === step.lane)
69 const lanes: Lane[] = hasLane ? turn.lanes : [...turn.lanes, { id: step.lane, label: laneLabel }]
70 return { ...turn, lanes, steps: [...turn.steps, step].slice(-MAX_STEPS) }
71}
72
73export function withStepChange(turns: readonly Turn[], id: string, change: Partial<Step>): Turn[] {
74 return turns.map(turn =>
75 turn.steps.some(step => step.id === id)
76 ? { ...turn, steps: turn.steps.map(step => (step.id === id ? { ...step, ...change } : step)) }
77 : turn,
78 )
79}
80
81export function relabel(turns: readonly Turn[], laneId: string, label: string): Turn[] {
82 return turns.map(turn => ({
83 ...turn,
84 lanes: turn.lanes.some(lane => lane.id === laneId)
85 ? turn.lanes.map(lane => (lane.id === laneId ? { ...lane, label } : lane))
86 : turn.lanes,
87 }))
88}
89
90export function seconds(ms: number): string {
91 if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`
92 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
93 const minutes = Math.floor(ms / 60_000)
94 return `${minutes}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`
95}
96
97/** Time the main lane spent outside tool calls: the model reading, deciding and writing. */
98export function modelTime(turn: Turn, now: number): number {
99 const end = turn.endedAt ?? now
100 const spans = turn.steps
101 .filter(step => step.lane === 'main')
102 .map(step => [step.startedAt, step.endedAt ?? now] as const)
103 .sort((a, b) => a[0] - b[0])
104 let busy = 0
105 let reach = turn.startedAt
106 for (const [start, stop] of spans) {
107 const from = Math.max(start, reach)
108 if (stop > from) busy += stop - from
109 reach = Math.max(reach, stop)
110 }
111 return Math.max(0, end - turn.startedAt - busy)
112}
113
114/** A waterfall bar: leading spaces then blocks, placed on the turn's time span. */
115export function bar(step: Step, turn: Turn, now: number, width: number): { pad: string; fill: string } {
116 const span = Math.max(1, (turn.endedAt ?? now) - turn.startedAt)
117 const start = Math.min(width - 1, Math.floor(((step.startedAt - turn.startedAt) / span) * width))
118 const length = Math.max(1, Math.round((((step.endedAt ?? now) - step.startedAt) / span) * width))
119 return { pad: ' '.repeat(Math.max(0, start)), fill: '█'.repeat(Math.min(length, width - start)) }
120}
121
122export function colorOf(step: Step, now: number): string {
123 if (step.status === 'running') return 'cyan'
124 if (step.status === 'error') return 'red'
125 if (step.status === 'denied') return 'magenta'
126 const took = (step.endedAt ?? now) - step.startedAt
127 return took >= 30_000 ? 'red' : took >= 10_000 ? 'yellow' : 'green'
128}
129
130export const BADGE: Record<StepStatus, string> = { running: '●', ok: '✓', error: '✗', denied: '⊘' }
131
132/** What a turn's prompt reads as: a background task's notice becomes its one-line summary instead of raw tags. */
133export function promptLabel(text: string): string {
134 const flat = text.replace(/\s+/g, ' ').trim()
135 if (flat === '') return '(continued without a prompt)'
136 if (flat.startsWith('<task-notification>')) {
137 const summary = /<summary>(.*?)<\/summary>/.exec(flat)?.[1]?.trim()
138 return `↻ ${summary === undefined || summary === '' ? 'a background task finished' : summary}`
139 }
140 return flat.slice(0, 160)
141}
142types/index.d.ts 170 lines1// ── Now: the turn as it runs ────────────────────────────────────────────
2
3export type StepStatus = 'running' | 'ok' | 'error' | 'denied'
4
5export type AgentRun = { agentId?: string; type: string; description: string; toolCount?: number; tokens?: number }
6
7export type Step = {
8 id: string
9 /** 'main', or the subagent's id. */
10 lane: string
11 tool: string
12 summary: string
13 startedAt: number
14 endedAt?: number
15 status: StepStatus
16 output: string
17 agent?: AgentRun
18}
19
20export type Lane = { id: string; label: string }
21
22export type TurnStatus = 'running' | 'done' | 'interrupted' | 'failed'
23
24export type Turn = {
25 id: string
26 prompt: string
27 startedAt: number
28 endedAt?: number
29 status: TurnStatus
30 steps: Step[]
31 lanes: Lane[]
32 inputTokens?: number
33 outputTokens?: number
34 /** What the turn cost in US dollars, from the session's cost ledger. */
35 costUsd?: number
36}
37
38// ── Changes: every edit, grouped by turn ────────────────────────────────
39
40export type EditHunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
41
42export type EditRecord = {
43 id: string
44 turnId: string
45 file: string
46 kind: 'edit' | 'create' | 'overwrite'
47 hunks: EditHunk[]
48 added: number
49 removed: number
50 /** Functions and classes this edit touched, in the file as it stood right after. */
51 symbols: string[]
52 at: number
53 isCut: boolean
54}
55
56export type SymbolKind = 'class' | 'function' | 'method'
57
58export type SymbolChange = { name: string; kind: SymbolKind; change: 'added' | 'modified' | 'removed'; line: number; callers: number }
59
60export type FileSummary = {
61 file: string
62 status: 'loading' | 'ready' | 'error'
63 /** Compared with the file as it was before Claude first touched it this session. */
64 symbols: SymbolChange[]
65 added: number
66 removed: number
67 /** The change as unified-diff text with context, cut at a hunk boundary to fit a Code element. */
68 diff: string
69 isDiffCut: boolean
70 error?: string
71}
72
73// ── Preview and Artifacts ───────────────────────────────────────────────
74
75export type PreviewKind = 'markdown' | 'json' | 'yaml' | 'csv' | 'html' | 'image' | 'svg' | 'code' | 'text'
76
77export type Preview = {
78 path: string
79 kind: PreviewKind
80 status: 'loading' | 'ready' | 'error'
81 text: string
82 size: number
83 /** For HTML and PNG: the absolute path of a PNG to draw. */
84 image?: string
85 generation: number
86 note?: string
87}
88
89export type ArtifactKind = 'doc' | 'image' | 'data' | 'web' | 'code' | 'other'
90
91export type Artifact = { path: string; kind: ArtifactKind; size: number; mtimeMs: number; isByClaude: boolean }
92
93// ── Code Map (from Codebase Atlas) ──────────────────────────────────────
94
95export type Group = { id: string; files: number; lines: number; symbols: number; level: number; /** Its files, at most 60. */ paths: string[] }
96export type Edge = { from: string; to: string; count: number }
97export type Graph = { root: string; isGit: boolean; files: number; groups: Group[]; edges: Edge[]; isTruncated: boolean }
98export type ScanStatus = 'idle' | 'scanning' | 'ready' | 'no-folder' | 'error'
99export type Site = { symbol: string; file: string; line: number; isKnown: boolean }
100
101export type OutlineEntry = {
102 name: string
103 kind: SymbolKind
104 line: number
105 endLine: number
106 depth: number
107 isChanged: boolean
108 calls: string[]
109}
110
111export type Outline = { file: string; status: 'loading' | 'ready' | 'error'; lines: number; entries: OutlineEntry[]; error?: string }
112
113export type CallView = {
114 symbol: string
115 status: 'loading' | 'ready' | 'missing' | 'error'
116 definition?: Site
117 callers: Site[]
118 callees: Site[]
119 error?: string
120}
121
122export type MapView = 'map' | 'components' | 'calls'
123
124// ── Usage ───────────────────────────────────────────────────────────────
125
126export type Reading = { percent: number; tokens: number; window: number }
127export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
128
129/** File path to its text before Claude's first edit this session; null for a file Claude created. */
130export type Baselines = Record<string, string | null>
131
132export type Tab = 'now' | 'changes' | 'preview' | 'artifacts' | 'map' | 'usage'
133
134/** Which slide the band above the prompt shows: ◀ Forecast, ▶ Workbench. */
135export type Slide = 'forecast' | 'workbench'
136
137declare module 'claude-code' {
138 interface PluginState {
139 workbench: {
140 tab: Tab
141 isHudHidden: boolean
142 slide: Slide
143 startedAt: number
144 turns: Turn[]
145 turnBack: number
146 showOutputs: boolean
147 edits: EditRecord[]
148 changeFile: string | null
149 changeEdit: string | null
150 fileSummary: FileSummary | null
151 preview: Preview | null
152 isFollowing: boolean
153 artifacts: Artifact[]
154 graph: Graph | null
155 scanStatus: ScanStatus
156 scanError: string
157 mapView: MapView
158 mapFocus: string | null
159 outline: Outline | null
160 call: CallView | null
161 trail: string[]
162 limits: Limit[]
163 reading: Reading | null
164 history: number[]
165 costUsd: number | null
166 baselines: Baselines
167 }
168 }
169}
170