SLOPSHOPPER

workbench

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…

newpanebandguardcommandtoast
v2.0.0no licenseupdated 2026-10-03harshitmywork17/claude-mods/plugins/workbench
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench
│ ┃ Workbench ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ◆ Workbench ✓ idle · 1 turn │ workbench │ │ ┃ ⏺ Read(src/auth.ts) │ Workbench is open. If Claude Code's diff │ │ ┃ Now Changes Preview Artifacts Map ⎿ Read 6 lines │ panel covers it, run /diff to hide that │ │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ panel. │ │ ┃ ──────────── ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ Turn 1 of 1 ✓ done · 480ms ⏺ Bash(bun test) │ ┃ “(before this view opened)” ⎿ 3 pass, 1 fail │ ┃ │ ┃ Read 1 Grep 1 Edit 1 Write 2 Bash 4 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ STEPS ─────────────────────────────── 9 call ✻ Worked for 42s · done 4:20 PM │ ┃ ✓ Read auth.ts │ ┃ ✓ Grep refresh\( › /wb │ ┃ ✓ Edit auth.ts │ ┃ ✓ Write audit.ts │ ┃ ✓ Write cache.ts │ ┃ ✗ Bash bun test │ ┃ ↳ src/auth.test.ts: ⏎ ✓ refreshes expired… │ ┃ ✓ Bash git status --porcelain │ ┃ ✓ Bash rm -rf build && git push --force o │ ┃ ✓ Bash cat .env │ ┃ │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── │ ┃ v: show outputs ◀ Workbench ○● ▶ ⏱ – · 9 tools · ✎ 3 files +0 −0 · ctx 49% open ▸ ✕ last edit src/cache.ts +0 −0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◀ Workbench ○● ▶ ⏱ – · 9 tools · ✎ 3 files +0 −0 · ctx 49% open ▸ ✕ last edit src/cache.ts +0 −0
Pane · Workbench
◆ Workbench ✓ idle · 1 turn Now Changes Preview Artifacts Map Usage ──────────────────────────────────────────────────────── Turn 1 of 1 ✓ done · 480ms “(before this view opened)” Read 1 Grep 1 Edit 1 Write 2 Bash 4 model 300ms STEPS ─────────────────────────────── 9 calls · 1 failed ✓ Read auth.ts 20ms ✓ Grep refresh\( 20ms ✓ Edit auth.ts 20ms ✓ Write audit.ts 20ms ✓ Write cache.ts 20ms ✗ Bash bun test 20ms ↳ src/auth.test.ts: ⏎ ✓ refreshes expired token ⏎ ✓ rejec… ✓ Bash git status --porcelain 20ms ✓ Bash rm -rf build && git push --force orig… 20ms ✓ Bash cat .env 20ms ──────────────────────────────────────────────────────── v: show outputs
README

harshit-mods

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.

PluginCommandWhat it does
usage-forecast/forecast, /forecast hide, /forecast showA 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 2Records 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-filesA 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/meterThe 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 paneRuns 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/missionA 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-minimapautomaticA 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 rescanOne 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 offOne 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 closeAn 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 pageA 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-infoFrom 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 taskGuidelines 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 workPython standards: SOLID, DRY, KISS and YAGNI; FastAPI project layout; naming; OWASP-aligned secure coding; linting and testing setup.

Use them on every device

Add this marketplace to your claude.ai account once:

  1. On claude.ai, open Customize, then Plugins, then Add, then Add marketplace.
  2. Enter harshitmywork17/claude-mods. If the repository is private, your GitHub account must be connected to Claude.
  3. Turn on each plugin. Turn on 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:

  1. claude --version must be 2.1.287 or later.
  2. 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.
  3. 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.

Use them in cloud sessions

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:

  1. Open the environment menu in a session's title bar, then Edit.
  2. Under Environment variables, add: `` CLAUDE_CODE_PLUGIN_DIRS=/home/user/claude-mods/plugins ``
  3. When you start a cloud session, select 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.

Install on one machine

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.

Develop

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.

Source 11 files
hooks/register.tsx 2180 lines
1import { 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 lines
1import 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}
94
hooks/changes.ts 188 lines
1import 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}
188
hooks/diff.ts 143 lines
1import 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}
143
hooks/forecast.ts 71 lines
1import 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}
71
hooks/files.ts 134 lines
1import 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}
134
hooks/limits.ts 86 lines
1import 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}
86
hooks/scan.ts 300 lines
1import 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}
300
hooks/theme.ts 81 lines
1/**
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}
81
hooks/trace.ts 142 lines
1import 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}
142
types/index.d.ts 170 lines
1// ── 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