Follow the agent: a live sidebar showing the file and lines it is reading, editing or writing right now, like Zed's follow mode

A Claude Code mod that follows the agent, like Zed's "follow" mode: a live sidebar showing the file and lines the agent is reading, editing or writing right now.
Read (the line range it read), Edit (the exact lines: found before the edit runs, then the diff once it lands), Write (diff against the old file, or the head of a new one), and shell reads and writes (cat, head, tail, sed -n A,Bp, >, tee; a leading cd <dir> && is followed). Any other command (a python3 - <<EOF script, sed -i, a formatter) is checked against the files it names: each one it changed shows as an edit with its diff. Edit and Write diffs are against the file as the tool found it, and a change held for review says so. A command that changed no file shows what it printed (stdout, then stderr) in the code window, scrolled to the end. It also logs searches, plan updates, web requests and subagents.edit main.js:88, shell git status 12s, gone a few seconds after it finishes) on a second row, over a row with the subagents at work, how many and of what type (2 agents Explore), and ci-watch's CI #N state. They replace the pinned status line.scratch (and ✎ in Files) so you can tell them apart from project files.● LIVE / ◎ SCROLLED / ◎ PINNED chip, the focused file as a block with an orange accent bar and a raised fill (kind, path:line, +N −M, then the code), Files and Trail as blocks with the same fill, right-aligned +N −M chips and ages (long paths shortened from the middle, the file name kept; a whole row lights under the pointer), changes bold and reads dim, and buttons along the bottom. Before anything happens the same layout draws blank, with an ○ IDLE chip and a legend of what each block will show.│, a change's ▌) or the whole diff for a change, old and new line numbers beside it. The gutter is as wide as the file's largest line number, so the code never shifts as you scroll, and long lines wrap at the pane's edge instead of being cut. Files and Trail scroll with the wheel over them too. Scroll the code with the mouse wheel over it, j / k, or t / e for top and end; the header shows ◎ SCROLLED with a [ ⟲ follow live ] button. It follows the agent again on its next move, or press f.main agent, Explore: find the parser), and a subagent's rows in Trail are marked ↳. Press p (or click a file or trail row) to pin a view, and f to follow live again.codeBackground in /config to none, or to any color such as #1e1e2e.nvimServer in /config to a nvim --listen address, and each focus change opens the file at that line in your Neovim too.The pane docks as a sidebar beside the transcript only in Claude Code's fullscreen layout, in a terminal at least 110 columns wide. It asks for a 64-column dock; drag the edge to resize it. On the main screen (the default inside tmux) it opens above the prompt instead. To get the sidebar:
CLAUDE_CODE_NO_FLICKER=1 claude
| Command | Does |
|---|---|
/follow | open the pane |
/follow live | unpin and follow live |
/follow clear | clear the trail (a /clear does too) |
In a Claude Code terminal session:
/plugin install follow --marketplace tidefox/claude-plugins
Answer y to add the marketplace, then pick a scope.
hooks/register.tsx 188 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Activity } from '../types'
5import { plan, trim, mirror } from './capture'
6import type { Plan } from './capture'
7import { ago, footerWhat, shortPath } from './labels'
8import { clip, shortenPath } from './layout'
9import { renderPane, scrollPane } from './pane'
10import { BADGE, DOCK_MIN_COLUMNS, LINGER_MS, PANE, SIDEBAR_COLUMNS, env, settings } from './state'
11
12// The engine reads a file's state by scanning it, so each file that reads or writes these declares them itself.
13const activities = atom({ plugin: 'follow', key: 'activities' } as const, [])
14const pinned = atom({ plugin: 'follow', key: 'pinned' } as const, null)
15const scroll = atom({ plugin: 'follow', key: 'scroll' } as const, null)
16/** How far the person scrolled the Files and Trail lists, in rows from the newest. */
17const lists = atom({ plugin: 'follow', key: 'lists' } as const, { files: 0, trail: 0 })
18
19async function reset($: EngineInterface) {
20 await update($, activities, () => [])
21 await update($, pinned, () => null)
22 await update($, scroll, () => null)
23 await update($, lists, () => ({ files: 0, trail: 0 }))
24 $.ui.status(undefined)
25}
26
27const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Follow', columns: SIDEBAR_COLUMNS, rows: 30 })
28
29export const register: Register = (on, options) => {
30 settings.nvimServer = String(options.nvimServer ?? '').trim()
31 settings.codeBackground = String(options.codeBackground ?? 'auto').trim() || 'auto'
32 settings.theme = null
33
34 on('session.start', async ($, e, next) => {
35 env.root = await $.session.root().catch(() => $.session.cwd())
36 env.home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
37 await $.command.register({
38 name: 'follow',
39 description: 'Follow the agent: open the live file pane, or `live`, `clear`',
40 argumentHint: '[live | clear]',
41 immediate: true,
42 })
43 void openPane($)
44
45 return next(e)
46 })
47
48 // A /clear starts a new conversation with no session.start: its trail goes too.
49 on('session.end', { reason: 'clear' }, async ($, e, next) => {
50 await reset($)
51 return next(e)
52 }).catch(($, e, next) => next(e))
53
54 // The status line names what the agent is doing; once the turn ends it is doing nothing.
55 on('turn.complete', async ($, e, next) => {
56 if (e.agentId === undefined) {
57 // Redraw the footer now, and again once the finished call's linger has run out.
58 $.ui.invalidate('ui.render')
59 $.clock.after(LINGER_MS + 500, () => $.ui.invalidate('ui.render'))
60 }
61 return next(e)
62 })
63
64 on('command.run', { command: 'follow' }, async ($, e) => {
65 const verb = e.args.trim()
66 if (verb === 'clear') {
67 await reset($)
68 return { text: 'Follow trail cleared.' }
69 }
70 if (verb === 'live') {
71 await update($, pinned, () => null)
72 await update($, scroll, () => null)
73 }
74 await openPane($)
75 const { isFullscreen, columns } = e.presentation
76 if (!isFullscreen) {
77 return {
78 text: 'Following the agent, above the prompt. For a sidebar beside the transcript, restart with CLAUDE_CODE_NO_FLICKER=1 (fullscreen layout) in a terminal at least 110 columns wide.',
79 }
80 }
81 if (columns < DOCK_MIN_COLUMNS) {
82 return { text: `Following the agent. Widen the terminal to ${DOCK_MIN_COLUMNS}+ columns (now ${columns}) to dock it as a sidebar.` }
83 }
84 return { text: 'Following the agent in the sidebar.' }
85 })
86
87 on('tool.call', async ($, e, next) => {
88 let p: Plan
89 try {
90 // The calling loop's directory: a subagent's own, a worktree's after a move.
91 const here = await $.session.cwd()
92 // /cd and a worktree move take the root along.
93 if (e.agentId === undefined) env.root = await $.session.root().catch(() => env.root || here)
94 p = await plan($, e, here)
95 } catch {
96 return next(e)
97 }
98 const activity: Activity = {
99 ...p.activity,
100 id: e.tool_use_id,
101 agentId: e.agentId ?? null,
102 tool: String(e.tool),
103 phase: 'running',
104 at: await $.clock.now(),
105 }
106 const keep = await read($, pinned)
107 await update($, activities, list => trim([activity, ...list.filter(a => a.id !== activity.id)], keep))
108 if (keep === null) void mirror($, activity)
109
110 const settle = (extra: Partial<Activity>, phase: Activity['phase']) =>
111 update($, activities, list => list.map((a): Activity => (a.id === activity.id ? { ...a, ...extra, phase } : a)))
112
113 let ran: ToolCallResult
114 try {
115 ran = await next(e)
116 } catch (error) {
117 // Interrupted or thrown: the row would otherwise read `…` for good.
118 await settle({}, 'error').catch(() => {})
119 throw error
120 }
121 const isError = ran.deny !== undefined || ran.isError === true
122 type After = Awaited<ReturnType<NonNullable<Plan['after']>>>
123 const { more = [], ...extra }: After = p.after && !isError ? await p.after(ran).catch(() => ({})) : {}
124 await settle(extra, isError ? 'error' : 'done')
125 if (more.length > 0) {
126 const also = more.map((m, i): Activity => ({ ...activity, ...m, id: `${activity.id}#${i + 1}`, phase: 'done' }))
127 const keep = await read($, pinned)
128 await update($, activities, list => {
129 const at = list.findIndex(a => a.id === activity.id)
130 return trim([...list.slice(0, at + 1), ...also, ...list.slice(at + 1)], keep)
131 })
132 }
133
134 return ran
135 }).catch(($, e, next) => next(e))
136
137 // The footer's right side, beside the statusline: what the main agent is doing now, so it shows with the pane closed.
138 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
139 const main = (await read($, activities)).filter(a => a.agentId === null)
140 const a = main.find(x => x.phase === 'running') ?? main[0]
141 const t = await $.clock.now()
142 const isRunning = a?.phase === 'running'
143 // A finished call lingers a few seconds, then the label goes quiet.
144 const doing = a && (isRunning || t - a.at <= LINGER_MS) ? a : null
145 // Subagents at work, by type: `2 agents Explore + general-purpose`, like the model and effort chip.
146 const working = (await $.agent.list().catch(() => [])).filter(g => g.status === 'running')
147 if (!doing && working.length === 0 && e.props.modes.length === 0) return next(e)
148 const { Box, Text } = $.ui.resolve(e)
149 const types = [...new Set(working.map(g => g.type))]
150 const what = !doing ? '' : doing.path === null ? footerWhat(doing.label) : `${shortenPath(shortPath(doing), 28)}${doing.line ? `:${doing.line}` : ''}`
151 const modes = e.props.modes.join(' & ')
152 // Two rows, right-aligned: the labels (CI, subagents) over what the agent is doing.
153 return (
154 <Box flexDirection="column" flexGrow={1} alignItems="flex-end">
155 {modes || working.length > 0 ? (
156 <Box flexDirection="row" gap={1} justifyContent="flex-end">
157 {modes ? <Text dimColor>{modes}</Text> : ''}
158 {working.length > 0 ? (
159 <Text>
160 <Text bold color="claude">{`${working.length} ${working.length === 1 ? 'agent' : 'agents'}`}</Text>
161 <Text dimColor>{` ${clip(types.join(' + '), 28)}`}</Text>
162 </Text>
163 ) : (
164 ''
165 )}
166 </Box>
167 ) : (
168 <Text> </Text>
169 )}
170 {doing ? (
171 <Box flexDirection="row" gap={1} justifyContent="flex-end">
172 <Text bold color={BADGE[doing.kind].color}>{BADGE[doing.kind].label.toLowerCase()}</Text>
173 <Text dimColor={!isRunning} wrap="truncate-end">{what}</Text>
174 {isRunning ? <Text dimColor>{ago(t - doing.at)}</Text> : ''}
175 </Box>
176 ) : (
177 <Text> </Text>
178 )}
179 </Box>
180 )
181 }).catch(($, e, next) => next(e))
182
183 on('ui.render', { component: 'Pane', requestId: PANE }, renderPane)
184
185 // The wheel over the code window scrolls the code, not the pane: the layout is fixed.
186 on('ui.scroll', { component: 'Pane', requestId: PANE }, scrollPane).catch(($, e, next) => next(e))
187}
188hooks/capture.ts 354 lines1import type { EngineInterface, ToolCallInput, ToolCallResult } from 'claude-code'
2
3import type { Activity, ActivityKind, FileView } from '../types'
4import { buildHunk } from './diff'
5import { BINARY, isScratchPath, namedPaths, parseShell, resultText, grepFiles, shellDir } from './parse'
6import { EDIT_CONTEXT, HUNK_CAP, MAX_ACTIVITIES, MAX_FILE_CHARS, MAX_VIEWS, OUTPUT_LINES, READ_WINDOW, env, settings } from './state'
7import { clean, lineOf, oneLine, splitLines } from './text'
8
9// ── capture ────────────────────────────────────────────────────────────────
10
11export const expandHome = (path: string) => (env.home && /^~(\/|$)/.test(path) ? `${env.home}${path.slice(1)}` : path)
12/** `path` against `dir` (the calling loop's directory: a subagent's may be a worktree of its own). */
13export const absolute = (path: string, dir: string) =>
14 path.startsWith('/') || !dir ? path : `${dir.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
15
16export function relative(path: string) {
17 if (env.root && path.startsWith(`${env.root}/`)) return path.slice(env.root.length + 1)
18 const scratch = /\/scratchpad\/(.*)$/.exec(path)
19 if (scratch) return `scratchpad/${scratch[1]}`
20 return path.replace(/^\/(?:home|Users)\/[^/]+(?=\/)/, '~')
21}
22
23export async function readText($: EngineInterface, path: string): Promise<string | null> {
24 try {
25 if (BINARY.test(path)) return null
26 const text = await $.fs.read(path)
27 return text.length > MAX_FILE_CHARS || text.includes('\0') ? null : text
28 } catch {
29 return null
30 }
31}
32
33/** The focused file's lines, kept while its size and mtime hold: every redraw would read and split it again. */
34let shownFile: { path: string; size: number; mtimeMs: number; lines: string[] } | null = null
35
36export async function fileLines($: EngineInterface, path: string): Promise<string[] | null> {
37 const stat = await $.fs.stat(path).catch(() => null)
38 const at = shownFile
39 if (stat && at && at.path === path && at.size === stat.size && at.mtimeMs === stat.mtimeMs) return at.lines
40 const text = await readText($, path)
41 const lines = text === null ? null : splitLines(text).map(clean)
42 shownFile = stat && lines ? { path, size: stat.size, mtimeMs: stat.mtimeMs, lines } : null
43 return lines
44}
45
46function sourceView(path: string, lines: string[], from: number, to: number, pad: number, limit: number): FileView {
47 const start = Math.max(1, from - pad)
48 const end = Math.min(lines.length, Math.min(to + pad, start + limit - 1))
49 return {
50 mode: 'source',
51 path,
52 startLine: start,
53 lines: lines.slice(start - 1, end).map(clean),
54 focusFrom: from,
55 focusTo: Math.min(to, lines.length),
56 }
57}
58
59function diffView(path: string, before: string, after: string): FileView | null {
60 const hunk = buildHunk(splitLines(before).map(clean), splitLines(after).map(clean))
61 return hunk && { mode: 'diff', path, ...hunk }
62}
63
64const diffFields = (view: FileView & { mode: 'diff' }): Partial<Activity> => ({
65 line: view.line,
66 view,
67 added: view.added,
68 removed: view.removed,
69})
70
71type Found = Omit<Activity, 'id' | 'agentId' | 'tool' | 'phase' | 'at'>
72
73export type Plan = {
74 activity: Found
75 /**
76 * Runs after the tool answered; what it returns is merged into the activity,
77 * and each of `more` (further files the call changed) is an activity of its own.
78 */
79 after?: (ran: ToolCallResult) => Promise<Partial<Activity> & { more?: Found[] }>
80}
81
82const base = (kind: ActivityKind, label: string, path: string | null = null) => ({
83 kind,
84 label,
85 path,
86 isScratch: path !== null && isScratchPath(path),
87 line: null,
88 endLine: null,
89 view: null,
90 added: null,
91 removed: null,
92})
93
94async function readPlan($: EngineInterface, path: string, from: number, to: number | null, label: string): Promise<Plan> {
95 const text = await readText($, path)
96 const lines = text === null ? [] : splitLines(text)
97 const end = Math.min(to ?? lines.length, lines.length || (to ?? from))
98 return {
99 activity: {
100 ...base('read', label, path),
101 line: from,
102 endLine: end,
103 view: text === null ? null : sourceView(path, lines, from, end, 0, READ_WINDOW),
104 },
105 }
106}
107
108/**
109 * What an Edit or Write result says beyond its text: the file as the tool found
110 * it (truer than a snapshot taken before the permission prompt), and whether
111 * the change was held for review rather than written.
112 */
113function toolRecord(ran: ToolCallResult): { originalFile?: string | null; staged?: boolean } {
114 const result = ran.isError || ran.deny !== undefined ? null : ran.result
115 return typeof result === 'object' && result !== null ? (result as { originalFile?: string | null; staged?: boolean }) : {}
116}
117
118async function writePlan($: EngineInterface, path: string, label: string, isTool = false): Promise<Plan> {
119 const snapshot = await readText($, path)
120 return {
121 activity: { ...base('write', label, path), line: 1 },
122 after: async ran => {
123 const record = isTool ? toolRecord(ran) : {}
124 if (record.staged === true) return { isHeld: true }
125 const before = typeof record.originalFile === 'string' ? record.originalFile : snapshot
126 const after = await readText($, path)
127 if (after === null) return {}
128 if (before === null) {
129 const lines = splitLines(after)
130 return { label: `${label} (new file)`, endLine: lines.length, added: lines.length, removed: 0, view: sourceView(path, lines, 1, 0, 0, READ_WINDOW) }
131 }
132 const view = diffView(path, before, after)
133 return view?.mode === 'diff' ? diffFields(view) : {}
134 },
135 }
136}
137
138/** What a command printed (stdout, then stderr), as a view the code window can scroll; null when it printed nothing. */
139function outputView(command: string, ran: ToolCallResult): FileView | null {
140 const r = ran.result as { stdout?: unknown; stderr?: unknown; interrupted?: unknown } | null | undefined
141 const text = [r?.stdout, r?.stderr].filter((t): t is string => typeof t === 'string' && t.trim() !== '').map(t => t.replace(/\n+$/, '')).join('\n')
142 if (text === '') return null
143 const lines = splitLines(text).map(clean)
144 return {
145 mode: 'output',
146 command: oneLine(command),
147 lines: lines.slice(0, OUTPUT_LINES),
148 isCut: lines.length > OUTPUT_LINES,
149 isFailed: r?.interrupted === true,
150 }
151}
152
153const GIT_STATS = 300
154const GIT_TEXTS = 50
155const GIT_SHOWN = 20
156/** A command that only looks: no tree to compare around it. */
157const LOOKS_ONLY = /^\s*(?:ls|cat|echo|pwd|grep|rg|head|tail|wc|which|date|stat|file|tree|du|df)\b/
158
159type Tree = { root: string; stats: Map<string, string> }
160
161/** The repo's uncommitted files (git status) as a size and mtime each; null outside a repo. */
162async function treeState($: EngineInterface, here: string): Promise<Tree | null> {
163 try {
164 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: here, timeoutMs: 3000 })
165 if (top.exitCode !== 0) return null
166 const root = top.stdout.trim()
167 const status = await $.process.run(['git', 'status', '--porcelain', '-z', '--untracked-files=all'], { cwd: root, timeoutMs: 5000 })
168 if (status.exitCode !== 0) return null
169 const entries = status.stdout.split('\0')
170 const stats = new Map<string, string>()
171 for (let i = 0; i < entries.length && stats.size < GIT_STATS; i++) {
172 const entry = entries[i]!
173 if (entry.length < 4) continue
174 // A rename names its old path in the entry after it.
175 if (entry[0] === 'R' || entry[0] === 'C') i++
176 const path = `${root}/${entry.slice(3)}`
177 const stat = await $.fs.stat(path).catch(() => null)
178 stats.set(path, stat ? `${stat.size}:${stat.mtimeMs}` : 'gone')
179 }
180 return { root, stats }
181 } catch {
182 return null
183 }
184}
185
186/**
187 * Any other command (a script, `sed -i`, `perl -pi`, a formatter) may change the
188 * files it names, or files it finds for itself: each named file is read before
189 * it runs and diffed after, and so is every file git sees change around it.
190 * The ones that changed are the call's edits.
191 */
192async function shellPlan($: EngineInterface, command: string, here: string): Promise<Plan> {
193 const dir = shellDir(command)
194 const paths = namedPaths(command)
195 .map(expandHome)
196 .filter(path => path.startsWith('/') || dir !== null)
197 .map(path => absolute(path, dir ? absolute(dir, here) : here))
198 const before = new Map<string, string | null>()
199 for (const path of paths) before.set(path, await readText($, path))
200
201 const cwd = dir ? absolute(dir, here) : here
202 const treeBefore = LOOKS_ONLY.test(command) ? null : await treeState($, cwd)
203 const dirty = new Map<string, string | null>()
204 if (treeBefore) {
205 for (const path of [...treeBefore.stats.keys()].slice(0, GIT_TEXTS)) if (!before.has(path)) dirty.set(path, await readText($, path))
206 }
207
208 return {
209 activity: base('shell', command),
210 after: async ran => {
211 const changed: Found[] = []
212 const record = (path: string, was: string | null, now: string | null) => {
213 if (now === null || now === was) return
214 if (was === null) {
215 const lines = splitLines(now)
216 changed.push({ ...base('write', command, path), line: 1, endLine: lines.length, added: lines.length, removed: 0, view: sourceView(path, lines, 1, 0, 0, READ_WINDOW) })
217 return
218 }
219 const view = diffView(path, was, now)
220 if (view?.mode === 'diff') changed.push({ ...base('edit', command, path), endLine: null, ...diffFields(view) })
221 }
222 for (const [path, was] of before) record(path, was, await readText($, path))
223
224 const treeAfter = treeBefore ? await treeState($, cwd) : null
225 if (treeBefore && treeAfter) {
226 const candidates = new Set([...treeBefore.stats.keys(), ...treeAfter.stats.keys()])
227 for (const path of candidates) {
228 if (changed.length >= GIT_SHOWN) break
229 if (before.has(path) || treeBefore.stats.get(path) === treeAfter.stats.get(path)) continue
230 let was: string | null
231 if (dirty.has(path)) was = dirty.get(path)!
232 else if (treeBefore.stats.has(path)) continue
233 else {
234 // Clean before: the committed file is what it was; absent from HEAD, it is new.
235 const shown = await $.process.run(['git', 'show', `HEAD:${path.slice(treeBefore.root.length + 1)}`], { cwd: treeBefore.root, timeoutMs: 5000 }).catch(() => null)
236 was = shown && shown.exitCode === 0 && !shown.isStdoutTruncated ? shown.stdout : null
237 }
238 record(path, was, await readText($, path))
239 }
240 }
241 const [first, ...more] = changed
242 if (first) return { ...first, more }
243 const view = outputView(command, ran)
244 return view ? { view } : {}
245 },
246 }
247}
248
249const GREP_FILES = 6
250
251/** Where a Grep matched: the first match's file and window on the call, each further file an activity of its own. */
252async function grepHits($: EngineInterface, ran: ToolCallResult, pattern: string, here: string, label: string): Promise<Partial<Activity> & { more?: Found[] }> {
253 const all = grepFiles(resultText(ran))
254 if (all.length === 0) return {}
255 let finder: RegExp | null = null
256 try {
257 finder = new RegExp(pattern, 'i')
258 } catch {}
259 const found: Found[] = []
260 for (const hit of all.slice(0, GREP_FILES)) {
261 const path = absolute(expandHome(hit.path), here)
262 const text = await readText($, path)
263 if (text === null) continue
264 const lines = splitLines(text)
265 let line = hit.line ?? 1
266 if (hit.line === null && finder) {
267 const at = lines.findIndex(l => finder!.test(l))
268 if (at >= 0) line = at + 1
269 }
270 found.push({ ...base('search', label, path), line, endLine: line, view: sourceView(path, lines, line, line, 3, READ_WINDOW) })
271 }
272 const note = `${label} (${all.length} file${all.length === 1 ? '' : 's'})`
273 const [first, ...more] = found
274 return first ? { ...first, label: note, more } : { label: note }
275}
276
277export async function plan($: EngineInterface, e: ToolCallInput, here: string): Promise<Plan> {
278 const tool = String(e.tool)
279 if (e.tool === 'Read') {
280 const path = absolute(e.file_path, here)
281 const from = Math.max(1, e.offset ?? 1)
282 return readPlan($, path, from, e.limit ? from + e.limit - 1 : null, e.file_path)
283 }
284 if (e.tool === 'Edit') {
285 const path = absolute(e.file_path, here)
286 const before = await readText($, path)
287 const at = before === null ? -1 : before.indexOf(e.old_string)
288 const line = at < 0 || before === null ? null : lineOf(before, at)
289 const endLine = line === null ? null : line + splitLines(e.old_string).length - 1
290 return {
291 activity: {
292 ...base('edit', e.replace_all ? `${e.file_path} (all)` : e.file_path, path),
293 line,
294 endLine,
295 view: before === null || line === null || endLine === null ? null : sourceView(path, splitLines(before), line, endLine, EDIT_CONTEXT, READ_WINDOW),
296 },
297 after: async ran => {
298 const record = toolRecord(ran)
299 if (record.staged === true) return { isHeld: true }
300 const from = typeof record.originalFile === 'string' ? record.originalFile : before
301 const after = ran.isError || ran.deny !== undefined || from === null ? null : await readText($, path)
302 const view = after === null || from === null ? null : diffView(path, from, after)
303 return view?.mode === 'diff' ? diffFields(view) : {}
304 },
305 }
306 }
307 if (e.tool === 'Write') return writePlan($, absolute(e.file_path, here), e.file_path, true)
308 if (e.tool === 'NotebookEdit') return { activity: base('edit', e.notebook_path, absolute(e.notebook_path, here)) }
309 if (e.tool === 'Bash') {
310 const found = parseShell(e.command)
311 const path = found && absolute(expandHome(found.path), found.dir === undefined ? here : absolute(found.dir, here))
312 if (found?.kind === 'read' && path) return readPlan($, path, found.from ?? 1, found.to ?? null, e.command)
313 if (found?.kind === 'write' && path) return writePlan($, path, e.command)
314 return shellPlan($, e.command, here)
315 }
316 if (tool === 'Grep' || tool === 'Glob') {
317 const args = e as unknown as { pattern?: string; path?: string }
318 const label = `${args.pattern ?? ''}${args.path ? ` in ${args.path}` : ''}`
319 if (tool === 'Glob') return { activity: base('search', label) }
320 return { activity: base('search', label), after: ran => grepHits($, ran, args.pattern ?? '', here, label) }
321 }
322 if (e.tool === 'TodoWrite') {
323 const doing = e.todos.find(t => t.status === 'in_progress')
324 return { activity: base('plan', doing?.activeForm ?? `${e.todos.length} todos`) }
325 }
326 if (e.tool === 'TaskCreate') return { activity: base('plan', e.subject) }
327 if (e.tool === 'WebFetch') return { activity: base('web', e.url) }
328 if (e.tool === 'WebSearch') return { activity: base('web', e.query) }
329 if (e.tool === 'Agent') return { activity: base('agent', e.description) }
330 return { activity: base('other', tool.replace(/^mcp__/, '').replace(/__/g, ' › ')) }
331}
332
333/** Newest first; only the newest few keep their excerpt. The pinned one stays, excerpt and all. */
334export function trim(list: Activity[], keep: string | null): Activity[] {
335 const kept = list.slice(0, MAX_ACTIVITIES)
336 const held = keep === null || kept.some(a => a.id === keep) ? undefined : list.find(a => a.id === keep)
337 return [...kept, ...(held ? [held] : [])].map((a, i) =>
338 i < MAX_VIEWS || a.id === keep || a.view === null ? a : { ...a, view: null },
339 )
340}
341
342const escapeVim = (path: string) => path.replace(/([ \\%#|"'])/g, '\\$1').replace(/</g, '<lt>')
343
344export async function mirror($: EngineInterface, a: Activity) {
345 if (!settings.nvimServer || a.path === null || a.kind === 'other') return
346 const keys = `<C-\\><C-N>:e +${a.line ?? 1} ${escapeVim(a.path)}<CR>zz`
347 try {
348 const run = await $.process.run(['nvim', '--server', settings.nvimServer, '--remote-send', keys], { timeoutMs: 2000 })
349 if (run.exitCode !== 0) $.ui.status(`follow: nvim at ${settings.nvimServer} did not answer`)
350 } catch {
351 $.ui.status('follow: nvim is not on PATH')
352 }
353}
354hooks/labels.ts 24 lines1import type { Activity } from '../types'
2import { relative } from './capture'
3import { clip, shortenPath } from './layout'
4import { oneLine } from './text'
5
6/** A command as the footer names it: its program and first word after, past `VAR=x` and `cd dir &&`. */
7export function footerWhat(label: string): string {
8 const words = oneLine(label.replace(/^.*?&&\s*/, '')).split(' ').filter(w => !/^\w+=/.test(w))
9 return clip(words.slice(0, 2).join(' '), 24)
10}
11
12export const shortPath = (a: Activity) =>
13 a.path === null ? oneLine(a.label) : `${relative(a.path)}${a.line ? `:${a.line}${a.endLine && a.endLine > a.line ? `-${a.endLine}` : ''}` : ''}`
14
15export function ago(ms: number) {
16 const s = Math.max(0, Math.round(ms / 1000))
17 if (s < 60) return `${s}s`
18 const m = Math.floor(s / 60)
19 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`
20}
21
22/** An activity's path:line (or its one-line label) in at most `room` cells, its file name kept. */
23export const fitActivity = (a: Activity, room: number) => (a.path === null ? clip(oneLine(a.label), room) : shortenPath(shortPath(a), room))
24hooks/layout.ts 153 lines1// ── layout and cells ───────────────────────────────────────────────────────
2
3/** Where the code window starts on its own: the focus a third of the way down. */
4export function autoTop(focusAt: number, total: number, rows: number): number {
5 return Math.min(Math.max(0, total - rows), Math.max(0, focusAt - Math.floor(rows / 3)))
6}
7
8/**
9 * The fixed layout's rows, top to bottom: header, gap, the focused block (title,
10 * info, code rows), then gap + heading + rows for Files and for Trail, and the
11 * key hints.
12 */
13export function codeLayout(bodyRows: number, fileRows: number, trailRows: number) {
14 const codeTop = 4
15 return { codeTop, codeRows: Math.max(4, bodyRows - codeTop - (2 + fileRows) - (2 + trailRows) - 1) }
16}
17
18// ── cells ──────────────────────────────────────────────────────────────────
19
20/** East Asian Wide and Fullwidth: CJK, and the emoji a terminal draws two cells wide. */
21const WIDE =
22 /[\u1100-\u115f\u231a-\u231b\u2329-\u232a\u23e9-\u23ec\u23f0\u23f3\u25fd-\u25fe\u2614-\u2615\u2648-\u2653\u267f\u2693\u26a1\u26aa-\u26ab\u26bd-\u26be\u26c4-\u26c5\u26ce\u26d4\u26ea\u26f2-\u26f3\u26f5\u26fa\u26fd\u2705\u270a-\u270b\u2728\u274c\u274e\u2753-\u2755\u2757\u2795-\u2797\u27b0\u27bf\u2b1b-\u2b1c\u2b50\u2b55\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\ua960-\ua97f\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{16fe0}-\u{18cff}\u{1b000}-\u{1b2ff}\u{1f004}\u{1f0cf}\u{1f18e}\u{1f191}-\u{1f19a}\u{1f1e6}-\u{1f1ff}\u{1f200}-\u{1f2ff}\u{1f300}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{1f7e0}-\u{1f7eb}\u{1f90c}-\u{1f9ff}\u{1fa70}-\u{1faff}\u{20000}-\u{3fffd}]/u
23const ZERO = /[\u0300-\u036f\u200b-\u200f\u20d0-\u20ff\ufe00-\ufe0f]/u
24const TAB = 4
25
26/** Terminal cells one character takes: two for wide (CJK, emoji), none for a combining mark. */
27const charCells = (ch: string) => (ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1)
28/** Printable ASCII, a cell a character: most source lines, measured and cut without a look at each character. */
29const ASCII = /^[ -~]*$/
30
31export function cellWidth(text: string): number {
32 if (ASCII.test(text)) return text.length
33 let n = 0
34 for (const ch of text) n += charCells(ch)
35 return n
36}
37
38/** Tabs as spaces to the next stop, so a line's width is known before it is drawn. */
39export function expandTabs(text: string): string {
40 if (!text.includes('\t')) return text
41 let out = ''
42 let col = 0
43 for (const ch of text) {
44 if (ch === '\t') {
45 const pad = TAB - (col % TAB)
46 out += ' '.repeat(pad)
47 col += pad
48 } else {
49 out += ch
50 col += charCells(ch)
51 }
52 }
53 return out
54}
55
56/** A line cut into rows of `room` cells, as a terminal wraps it: never none, a wide character never split. */
57export function wrapCells(text: string, room: number): string[] {
58 if (ASCII.test(text)) {
59 if (text.length <= room) return [text]
60 return Array.from({ length: Math.ceil(text.length / room) }, (_, i) => text.slice(i * room, (i + 1) * room))
61 }
62 const rows: string[] = []
63 let row = ''
64 let col = 0
65 for (const ch of text) {
66 const w = charCells(ch)
67 if (col + w > room && col > 0) {
68 rows.push(row)
69 row = ''
70 col = 0
71 }
72 row += ch
73 col += w
74 }
75 rows.push(row)
76 return rows
77}
78
79/** `text` in at most `room` cells: cut at the end with `…`. */
80export function clip(text: string, room: number): string {
81 if (cellWidth(text) <= room) return text
82 if (room <= 1) return room === 1 ? '…' : ''
83 let out = ''
84 let col = 0
85 for (const ch of text) {
86 const w = charCells(ch)
87 if (col + w > room - 1) break
88 out += ch
89 col += w
90 }
91 return `${out}…`
92}
93
94/** `text` in at most `room` cells: cut at the start with `…`, its end kept. */
95export function clipStart(text: string, room: number): string {
96 if (cellWidth(text) <= room) return text
97 if (room <= 1) return room === 1 ? '…' : ''
98 let out = ''
99 let col = 0
100 for (const ch of Array.from(text).reverse()) {
101 const w = charCells(ch)
102 if (col + w > room - 1) break
103 out = ch + out
104 col += w
105 }
106 return `…${out}`
107}
108
109/** A path in at most `room` cells: its first directory and file name kept, the directories between elided from the middle. */
110export function shortenPath(path: string, room: number): string {
111 if (cellWidth(path) <= room) return path
112 const dirs = path.split('/')
113 const name = dirs.pop() ?? ''
114 for (let keep = dirs.length - 2; keep >= 0; keep--) {
115 const short = [dirs[0], '…', ...dirs.slice(dirs.length - keep), name].join('/')
116 if (cellWidth(short) <= room) return short
117 }
118 const bare = `…/${name}`
119 return dirs.length > 0 && cellWidth(bare) <= room ? bare : clipStart(path, room)
120}
121
122/**
123 * One row of the code window: a piece of a line, the line's numbers on either side and its diff
124 * marker; or, between two hunks of a diff, a gap.
125 */
126export type CodeRow = { line: number; part: number; text: string; t: ' ' | '-' | '+'; old: number; new: number; isGap?: true; whole?: string; offset?: number }
127
128/** Lines hard-wrapped to `room` cells, tabs expanded: one CodeRow per row the window draws, a gap where the hunk changes. */
129export function wrapRows(lines: { text: string; t: CodeRow['t']; old: number; new: number; hunk?: number }[], room: number): CodeRow[] {
130 const rows: CodeRow[] = []
131 lines.forEach((l, line) => {
132 const before = lines[line - 1]
133 if (before && before.hunk !== l.hunk) rows.push({ line, part: 0, text: '⋯', t: ' ', old: 0, new: 0, isGap: true })
134 const whole = expandTabs(l.text)
135 const parts = wrapCells(whole, Math.max(1, room))
136 // A wrapped line keeps its whole text and where each piece starts, so the piece is drawn cut from the highlighted line.
137 let offset = 0
138 parts.forEach((text, part) => {
139 rows.push({ line, part, text, t: l.t, old: l.old, new: l.new, ...(parts.length > 1 ? { whole, offset } : {}) })
140 offset += cellWidth(text)
141 })
142 })
143 return rows
144}
145
146/** A scrollbar's thumb over `rows` cells for a window at `top` of `total`: where it starts and how long. */
147export function thumb(top: number, total: number, rows: number): { from: number; size: number } {
148 if (total <= rows) return { from: 0, size: rows }
149 const size = Math.max(1, Math.round((rows * rows) / total))
150 const from = Math.round((top / (total - rows)) * (rows - size))
151 return { from: Math.min(rows - size, Math.max(0, from)), size }
152}
153hooks/pane.tsx 550 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderInput, UiScrollInput, UiScrollResult } from 'claude-code'
3
4import type { Activity, ActivityKind } from '../types'
5import { fileLines, mirror, relative } from './capture'
6import { diffRows } from './diff'
7import { ago, fitActivity } from './labels'
8import type { CodeRow } from './layout'
9import { autoTop, cellWidth, codeLayout, shortenPath, thumb, wrapRows } from './layout'
10import { isScratchPath } from './parse'
11import { ACTION, AGE, BADGE, CHIP, FILE_ROWS, HUNK_CONTEXT, LETTER, SCROLL_STEP, TRAIL_ROWS, settings } from './state'
12import { oneLine } from './text'
13
14// The engine reads a file's state by scanning it, so each file that reads or writes these declares them itself.
15const activities = atom({ plugin: 'follow', key: 'activities' } as const, [])
16const pinned = atom({ plugin: 'follow', key: 'pinned' } as const, null)
17const scroll = atom({ plugin: 'follow', key: 'scroll' } as const, null)
18/** How far the person scrolled the Files and Trail lists, in rows from the newest. */
19const lists = atom({ plugin: 'follow', key: 'lists' } as const, { files: 0, trail: 0 })
20
21type PaneRender = Extract<RenderInput, { component: 'Pane' }>
22type PaneScroll = UiScrollInput
23
24async function theme($: EngineInterface): Promise<string> {
25 settings.theme ??= String((await $.config.list().catch(() => [])).find(row => row.key === 'theme')?.value ?? '')
26 return settings.theme
27}
28
29/** A terminal's prompt, drawn before the command and again after its output. */
30const PROMPT = '$ '
31
32/** The code window's fill: a shade dimmer than the terminal's, by theme, unless set in /config. */
33async function codeBackground($: EngineInterface): Promise<string | undefined> {
34 if (settings.codeBackground === 'none') return undefined
35 if (settings.codeBackground !== 'auto') return settings.codeBackground
36 const name = await theme($)
37 return /light/.test(name) ? '#eeeeee' : /ansi/.test(name) ? undefined : '#1c1c1c'
38}
39
40/** A Files or Trail row under the pointer: a step off the fill, by theme. */
41async function rowHover($: EngineInterface): Promise<string> {
42 const name = await theme($)
43 return /light/.test(name) ? '#dcdcdc' : /ansi/.test(name) ? 'subtle' : '#303030'
44}
45
46/** What the code window last drew: the wheel and j/k move it from there. */
47let drawn: { id: string; top: number; maxTop: number; codeTop: number; codeRows: number } | null = null
48
49async function scrollCode($: EngineInterface, by: number) {
50 const at = drawn
51 if (at === null) return
52 const top = Math.min(at.maxTop, Math.max(0, at.top + by))
53 if (top === at.top) return
54 drawn = { ...at, top }
55 await update($, scroll, () => ({ id: at.id, top }))
56}
57
58/** Where Files and Trail last drew (their rows from the pane's top), so the wheel over one scrolls that list. */
59let drawnLists: { name: 'files' | 'trail'; from: number; rows: number; top: number; maxTop: number }[] = []
60
61async function scrollList($: EngineInterface, name: 'files' | 'trail', by: number) {
62 const at = drawnLists.find(l => l.name === name)
63 if (!at) return
64 const top = Math.min(at.maxTop, Math.max(0, at.top + by))
65 if (top === at.top) return
66 at.top = top
67 await update($, lists, cur => ({ ...cur, [name]: top }))
68}
69
70/** The last wrap, kept while its lines and width hold: a redraw would wrap the whole file again. */
71let wrapped: { of: unknown; room: number; rows: CodeRow[] } | null = null
72function wrapOnce(of: unknown, room: number, lines: () => Parameters<typeof wrapRows>[0]): CodeRow[] {
73 if (wrapped && wrapped.of === of && wrapped.room === room) return wrapped.rows
74 const rows = wrapRows(lines(), room)
75 wrapped = { of, room, rows }
76 return rows
77}
78
79export async function renderPane($: EngineInterface, e: PaneRender) {
80 const { Box, Text, Button, Code } = $.ui.resolve(e)
81 const list = await read($, activities)
82 const stored = await read($, pinned)
83 // Only an activity id still in the trail pins; anything else follows live.
84 const pin = typeof stored === 'string' && list.some(a => a.id === stored) ? stored : null
85 const scrolled = await read($, scroll)
86 const listTops = await read($, lists)
87 const t = await $.clock.now()
88 const width = Math.max(24, e.props.bodyColumns)
89 // Every loop's activity, the main agent's and each subagent's, newest first.
90 const pool = list
91 const pinnedOne = list.find(a => a.id === pin)
92 const focus = pinnedOne ?? pool.find(a => a.view !== null) ?? pool.find(a => a.path !== null)
93 const isIdle = list.length === 0
94
95 // The header names the agent whose move is on show.
96 const shownAgent = (focus ?? pool[0])?.agentId ?? null
97 let who = isIdle ? 'waiting for Claude' : 'main agent'
98 if (shownAgent !== null) {
99 const agent = (await $.agent.list().catch(() => [])).find(a => a.id === shownAgent)
100 who = agent ? `${agent.type}: ${agent.description}` : 'subagent'
101 }
102
103 const badge = (a: Activity) => (
104 <Text>
105 <Text bold color="inverseText" backgroundColor={BADGE[a.kind].color}>{` ${BADGE[a.kind].label} `}</Text>
106 {a.isScratch ? <Text bold color="inverseText" backgroundColor="remember">{' SCRATCH '}</Text> : ''}
107 </Text>
108 )
109 const stat = (added: number | null, removed: number | null) =>
110 added == null || removed == null ? (
111 ''
112 ) : (
113 <Text>
114 {' '}
115 <Text bold backgroundColor="#1d460f">{` +${added} `}</Text>
116 <Text bold backgroundColor="#540d06">{` −${removed} `}</Text>
117 </Text>
118 )
119 const statWidth = (added: number | null, removed: number | null) =>
120 added == null || removed == null ? 0 : `${added}${removed}`.length + 7
121 const mark = (a: Activity) => (a.phase === 'running' ? '…' : a.phase === 'error' ? '✗' : a.isHeld ? 'held' : '')
122 const age = (a: Activity) => (mark(a) || ago(t - a.at)).padStart(AGE)
123
124 // A fixed layout as tall as the body: the code window takes what the header,
125 // Files and Trail leave, and scrolls inside itself while they stay put.
126 const rows = Math.max(16, e.props.scroll.bodyRows)
127 const isRoomy = rows >= 34
128 const fileRows = isRoomy ? FILE_ROWS : 3
129 const trailRows = isRoomy ? TRAIL_ROWS : 3
130 const layout = codeLayout(rows, fileRows, trailRows)
131 // A block's text runs between the pane's padding, its accent bar and its own padding.
132 const inner = width - 5
133
134 // What the code window scrolls over: the whole file for a read, the diff for a change,
135 // each line hard-wrapped into rows under a gutter as wide as its largest line number.
136 let codeRows: CodeRow[] = []
137 let codeAuto = 0
138 let gutter = 0
139 let digits = 1
140 let isFocusLine = (_row: CodeRow) => false
141 let place = (_from: CodeRow, _to: CodeRow) => ''
142 const fit = (lines: { old: number; new: number }[], extra: number) => {
143 digits = String(lines.reduce((n, l) => Math.max(n, l.old, l.new), 1)).length
144 gutter = extra + digits * (extra > 3 ? 2 : 1)
145 // The scrollbar's column is kept whether it shows or not: the wrap must not depend on it.
146 return Math.max(8, inner - 1 - gutter)
147 }
148 if (focus?.view?.mode === 'diff') {
149 const view = focus.view
150 const diff = diffRows(view.hunk)
151 // old new ±: two numbers, a space after each, the sign and a space.
152 const room = fit(diff, 4)
153 codeRows = wrapOnce(view.hunk, room, () => diff)
154 codeAuto = Math.max(0, codeRows.findIndex(r => r.t !== ' ') - HUNK_CONTEXT)
155 place = (from, to) => `diff lines ${from.line + 1}–${to.line + 1} of ${diff.length}${view.isCut ? ' (cut)' : ''}`
156 } else if (focus?.view?.mode === 'output') {
157 const view = focus.view
158 // The tail is what a command's output is usually read for: start at the end.
159 // A terminal has no gutter: the command after a prompt, its output, then the prompt waiting again.
160 gutter = 0
161 const room = Math.max(8, inner - 1)
162 codeRows = wrapOnce(view, room, () => [`${PROMPT}${view.command}`, ...view.lines, PROMPT].map((text, i) => ({ text, t: ' ' as const, old: i, new: i })))
163 codeAuto = Math.max(0, codeRows.length - layout.codeRows)
164 place = (from, to) => `output lines ${Math.max(1, from.new)}–${Math.min(to.new, view.lines.length)} of ${view.lines.length}${view.isCut ? ' (cut)' : ''}${view.isFailed ? ' · interrupted' : ''}`
165 } else if (focus?.path) {
166 const whole = await fileLines($, focus.path)
167 const view = focus.view?.mode === 'source' ? focus.view : null
168 const lines = whole ?? view?.lines ?? []
169 const first = whole !== null ? 1 : view?.startLine ?? 1
170 const focusFrom = focus.line ?? view?.focusFrom ?? first
171 const focusTo = Math.max(focusFrom, focus.endLine ?? view?.focusTo ?? focusFrom)
172 const numbered = () => lines.map((text, i) => ({ text, t: ' ' as const, old: first + i, new: first + i }))
173 // mark, number, a space.
174 const room = fit([{ old: 0, new: first + lines.length - 1 }], 2)
175 codeRows = wrapOnce(lines, room, numbered)
176 const at = codeRows.findIndex(r => r.new >= focusFrom)
177 codeAuto = autoTop(at < 0 ? 0 : at, codeRows.length, layout.codeRows)
178 isFocusLine = row => row.new >= focusFrom && row.new <= focusTo
179 place = (from, to) => `lines ${from.new}–${to.new} of ${first + lines.length - 1}`
180 }
181 const codeTotal = codeRows.length
182
183 const maxTop = Math.max(0, codeTotal - layout.codeRows)
184 const isScrolled = focus !== undefined && scrolled !== null && scrolled.id === focus.id
185 const top = Math.min(maxTop, Math.max(0, isScrolled ? scrolled.top : codeAuto))
186 drawn = focus ? { id: focus.id, top, maxTop, codeTop: layout.codeTop, codeRows: layout.codeRows } : null
187 const shown = codeRows.slice(top, top + layout.codeRows)
188 const where = shown.length > 0 ? place(shown[0]!, shown[shown.length - 1]!) : ''
189
190 const TONE = { ' ': undefined, '+': '#0e2705', '-': '#370603' } as const
191 const drawCode = () => {
192 if (shown.length === 0) return <Text dimColor>(no preview)</Text>
193 if (focus?.view?.mode === 'output') {
194 const end = focus.view.lines.length + 1
195 return (
196 <Box flexDirection="column">
197 {shown.map((row, i) => {
198 const isPrompt = row.new === 0 || row.new === end
199 if (!isPrompt) return <Text key={`t:${top + i}`} wrap="truncate-end">{row.text || ' '}</Text>
200 // Only a prompt's first row carries the `$`; a wrapped command goes on beneath it.
201 const isFirst = row.part === 0
202 return (
203 <Text key={`t:${top + i}`} wrap="truncate-end" bold={row.new === 0}>
204 {isFirst ? <Text bold color="success">{PROMPT.trim()} </Text> : ''}
205 {isFirst ? row.text.slice(PROMPT.length) : row.text}
206 {row.new === end ? <Text inverse> </Text> : ''}
207 </Text>
208 )
209 })}
210 </Box>
211 )
212 }
213 const isDiff = focus?.view?.mode === 'diff'
214 const mark = focus?.kind === 'read' ? '│' : '▌'
215 const markColor = focus?.kind === 'read' ? 'suggestion' : 'warning'
216 const num = (n: number, isShown: boolean) => (isShown ? String(n) : '').padStart(digits)
217 // A row with no number still draws a dot: a gutter row of blanks alone can be dropped or misplaced by the renderer,
218 // which sets every number after a wrapped line a row out of step with its code.
219 const cont = () => '·'.padStart(digits)
220 // Runs of one tone share a Code, so a diff's rows take their background and a file stays one highlight.
221 const runs: CodeRow[][] = []
222 for (const row of shown) {
223 const last = runs[runs.length - 1]
224 if (last && last[0]!.t === row.t && !last[0]!.isGap && !row.isGap && last[0]!.whole === undefined && row.whole === undefined) last.push(row)
225 else runs.push([row])
226 }
227 return (
228 <Box flexDirection="row">
229 <Box flexDirection="column" flexShrink={0} width={gutter}>
230 {shown.map((row, i) => {
231 const isFirst = row.part === 0 && !row.isGap
232 if (isDiff) {
233 return (
234 <Text key={`g:${top + i}`} dimColor={row.t === ' '} backgroundColor={TONE[row.t]}>
235 {`${num(row.old, isFirst && row.t !== '+')} ${num(row.new, isFirst && row.t !== '-')} ${isFirst ? row.t : '·'} `}
236 </Text>
237 )
238 }
239 const isFocus = isFocusLine(row)
240 return (
241 <Text key={`g:${top + i}`} dimColor={!isFocus} bold={isFocus} color={isFocus ? markColor : undefined}>
242 {`${isFocus ? mark : ' '}${isFirst ? num(row.new, true) : cont()} `}
243 </Text>
244 )
245 })}
246 </Box>
247 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
248 {runs.map((run, i) => (
249 <Box key={`run:${i}`} height={run.length} backgroundColor={TONE[run[0]!.t]}>
250 {run[0]!.isGap ? (
251 <Text dimColor>⋯</Text>
252 ) : run[0]!.whole !== undefined ? (
253 // Highlighted as the whole line, then cut to this piece: a string or comment that wraps keeps its colour.
254 <Box overflow="hidden" flexGrow={1} height={1}>
255 <Box flexShrink={0} width={cellWidth(run[0]!.whole)} marginLeft={-(run[0]!.offset ?? 0)}>
256 <Code source={run[0]!.whole || ' '} path={focus?.path ?? ''} wrap="truncate-end" />
257 </Box>
258 </Box>
259 ) : (
260 <Code source={run.map(r => r.text).join('\n') || ' '} path={focus?.path ?? ''} wrap="truncate-end" />
261 )}
262 </Box>
263 ))}
264 </Box>
265 </Box>
266 )
267 }
268
269 const files: { path: string; letters: Set<string>; latest: Activity; added: number | null; removed: number | null }[] = []
270 for (const a of pool) {
271 if (a.path === null) continue
272 let f = files.find(x => x.path === a.path)
273 if (!f) {
274 f = { path: a.path, letters: new Set(), latest: a, added: null, removed: null }
275 files.push(f)
276 }
277 const letter = LETTER[a.kind]
278 if (letter) f.letters.add(letter)
279 // Rows kept from before counts were recorded have none.
280 if (a.added != null && a.removed != null) {
281 f.added = (f.added ?? 0) + a.added
282 f.removed = (f.removed ?? 0) + a.removed
283 }
284 }
285
286 const filesMax = Math.max(0, files.length - fileRows)
287 const trailMax = Math.max(0, pool.length - trailRows)
288 const filesTop = Math.min(filesMax, Math.max(0, listTops.files))
289 const trailTop = Math.min(trailMax, Math.max(0, listTops.trail))
290 // Rows from the pane's top: the code block ends at codeTop + codeRows, each list is a gap, a heading and its rows.
291 const filesFrom = layout.codeTop + layout.codeRows + 1
292 const trailFrom = filesFrom + 2 + fileRows
293 drawnLists = [
294 { name: 'files', from: filesFrom, rows: 1 + fileRows, top: filesTop, maxTop: filesMax },
295 { name: 'trail', from: trailFrom, rows: 1 + trailRows, top: trailTop, maxTop: trailMax },
296 ]
297 const span = (top: number, total: number, rows: number) => (total > rows ? `${top + 1}–${Math.min(total, top + rows)} of ${total}` : String(total))
298
299 const followLive = () => Promise.all([update($, pinned, () => null), update($, scroll, () => null)])
300 const panel = await codeBackground($)
301 const isLive = pin === null && !isScrolled
302 const chip = isIdle ? { text: '○ IDLE', color: 'inactive' } : pin !== null ? { text: '◎ PINNED', color: 'warning' } : isScrolled ? { text: '◎ SCROLLED', color: 'warning' } : { text: '● LIVE', color: 'claude' }
303 const isChange = (kind: ActivityKind) => kind === 'edit' || kind === 'write'
304
305 // A block as OpenCode draws one: an accent bar down its left, a raised fill.
306 const block = (key: string, accent: string, height: number, body: unknown) => (
307 <Box key={key} flexDirection="row" marginTop={1} height={height}>
308 <Box flexDirection="column" flexShrink={0}>
309 {Array.from({ length: height }, () => (
310 <Text color={accent}>┃</Text>
311 ))}
312 </Box>
313 <Box flexDirection="column" flexGrow={1} flexShrink={1} backgroundColor={panel} paddingX={1} overflow="hidden">
314 {body as never}
315 </Box>
316 </Box>
317 )
318 const heading = (title: string, note: string) => (
319 <Box flexDirection="row" height={1}>
320 <Box flexGrow={1} flexShrink={1}>
321 <Text bold dimColor wrap="truncate-end">{title}</Text>
322 </Box>
323 <Box flexShrink={0}>
324 <Text dimColor>{note}</Text>
325 </Box>
326 </Box>
327 )
328
329 const grip = thumb(top, codeTotal, layout.codeRows)
330 const totalAdded = files.reduce((n, f) => n + (f.added ?? 0), 0)
331 const totalRemoved = files.reduce((n, f) => n + (f.removed ?? 0), 0)
332 const kindName = focus ? BADGE[focus.kind].label.charAt(0) + BADGE[focus.kind].label.slice(1).toLowerCase() : ''
333 const focusAge = focus ? age(focus) : ''
334 const focusRoom = focus
335 ? inner - cellWidth(kindName) - (focus.isScratch ? 8 : 0) - 1 - statWidth(focus.added, focus.removed) - 1 - AGE
336 : 0
337 const hover = await rowHover($)
338
339 return (
340 <Box flexDirection="column" paddingX={1} height={rows} overflow="hidden">
341 <Box flexDirection="row" height={1}>
342 <Box flexGrow={1} flexShrink={1}>
343 <Text wrap="truncate-end">
344 <Text bold color="claude">Follow</Text>
345 <Text dimColor> {who}</Text>
346 </Text>
347 </Box>
348 {/* Both slots keep their width whatever they hold, so the header never shifts. */}
349 <Box flexDirection="row" gap={1} flexShrink={0}>
350 <Box width={CHIP} flexShrink={0}>
351 <Text bold color={chip.color}>{chip.text}</Text>
352 </Box>
353 <Box width={ACTION} flexShrink={0} justifyContent="flex-end">
354 {isLive ? (
355 focus ? <Button key="pin" label="pin" hotkey="p" onPress={() => update($, pinned, () => focus.id)} /> : ''
356 ) : (
357 <Button key="live" label="⟲ follow live" variant="primary" hotkey="f" onPress={followLive} />
358 )}
359 </Box>
360 </Box>
361 </Box>
362
363 {focus ? (
364 block(
365 'focus',
366 isLive ? 'claude' : 'warning',
367 layout.codeRows + 2,
368 <Box flexDirection="column">
369 <Box flexDirection="row" height={1}>
370 {/* The kind, the chips and the age keep their cells: only the path gives way. */}
371 <Box flexShrink={0}>
372 <Text bold color={BADGE[focus.kind].color}>{kindName}</Text>
373 {focus.isScratch ? <Text color="remember"> scratch</Text> : ''}
374 </Box>
375 <Box flexGrow={1} flexShrink={1}>
376 <Text wrap="truncate-start" bold dimColor>{` ${fitActivity(focus, focusRoom)}`}</Text>
377 </Box>
378 <Box flexShrink={0}>
379 {stat(focus.added, focus.removed)}
380 <Text dimColor>{` ${focusAge}`}</Text>
381 </Box>
382 </Box>
383 <Box flexDirection="row" height={1}>
384 <Box flexGrow={1} flexShrink={1}>
385 <Text dimColor wrap="truncate-end">
386 {focus.isHeld ? 'held for review: the file is unchanged' : focus.tool === 'Bash' && focus.view?.mode !== 'output' ? `$ ${oneLine(focus.label)}` : where}
387 </Text>
388 </Box>
389 <Box flexShrink={0} width={5} justifyContent="flex-end">
390 <Text dimColor>{maxTop > 0 ? `${Math.round((top / maxTop) * 100)}%` : ''}</Text>
391 </Box>
392 </Box>
393 <Box key="code" flexDirection="row" height={layout.codeRows} overflow="hidden">
394 <Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
395 {drawCode() as never}
396 </Box>
397 {/* The scrollbar's column stays, blank when nothing scrolls: the code never changes width. */}
398 <Box flexDirection="column" flexShrink={0} width={1}>
399 {Array.from({ length: layout.codeRows }, (_, i) => {
400 const isThumb = i >= grip.from && i < grip.from + grip.size
401 return (
402 <Text key={`bar:${i}`} color={isThumb ? 'claude' : 'subtle'}>
403 {maxTop === 0 ? ' ' : isThumb ? '┃' : '│'}
404 </Text>
405 )
406 })}
407 </Box>
408 </Box>
409 </Box>,
410 )
411 ) : isIdle ? (
412 block(
413 'idle',
414 'claude',
415 layout.codeRows + 2,
416 <Box flexDirection="column">
417 <Text bold color="claude">◉ Nothing yet. Watching for Claude's first move.</Text>
418 <Text dimColor wrap="wrap">Every file Claude reads, edits or writes opens here, at the line it is working on.</Text>
419 <Box marginTop={1} flexDirection="row" gap={1}>
420 {(['read', 'edit', 'write'] as const).map(k => (
421 <Text key={`k:${k}`} bold color="inverseText" backgroundColor={BADGE[k].color}>{` ${BADGE[k].label} `}</Text>
422 ))}
423 <Text dimColor>open the file</Text>
424 </Box>
425 <Box flexDirection="row" gap={1}>
426 {(['shell', 'search', 'web', 'agent'] as const).map(k => (
427 <Text key={`k:${k}`} bold color={BADGE[k].color}>{BADGE[k].label.toLowerCase()}</Text>
428 ))}
429 <Text dimColor>logged in the trail</Text>
430 </Box>
431 <Box marginTop={1} flexDirection="column">
432 <Text>
433 <Text color="suggestion">│</Text>
434 <Text dimColor> lines read </Text>
435 <Text color="warning">▌</Text>
436 <Text dimColor> lines changed </Text>
437 <Text bold backgroundColor="#1d460f">{' +N '}</Text>
438 <Text bold backgroundColor="#540d06">{' −M '}</Text>
439 </Text>
440 <Text dimColor wrap="wrap">p pin · f follow live · j/k scroll · /follow clear</Text>
441 </Box>
442 </Box>,
443 )
444 ) : (
445 block('nofile', 'subtle', layout.codeRows + 2, <Text dimColor>No file touched yet.</Text>)
446 )}
447
448 {block(
449 'files',
450 'subtle',
451 fileRows + 1,
452 <Box flexDirection="column">
453 {heading('Files', span(filesTop, files.length, fileRows))}
454 {files.length === 0 ? <Text dimColor>○ none yet</Text> : ''}
455 {files.slice(filesTop, filesTop + fileRows).map(f => {
456 const isChanged = f.letters.has('E') || f.letters.has('W')
457 const isScratch = isScratchPath(f.path)
458 // ● and a space, the ✎, then the chips or ` read` on the right.
459 const room = inner - 2 - (isScratch ? 2 : 0) - (isChanged ? statWidth(f.added, f.removed) : 5)
460 return (
461 // The whole row lights under the pointer, not only its text.
462 <Box key={`f:${f.path}`} flexDirection="row" height={1} hover={{ backgroundColor: hover }}>
463 <Box flexGrow={1} flexShrink={1}>
464 <Button key={`file:${f.path}`} plain onPress={() => update($, pinned, () => f.latest.id)}>
465 {isChanged ? <Text color="warning">●</Text> : <Text color="suggestion">○</Text>}{' '}
466 <Text bold={isChanged} dimColor={!isChanged}>
467 {shortenPath(relative(f.path), room)}
468 </Text>
469 {isScratch ? <Text color="remember"> ✎</Text> : ''}
470 </Button>
471 </Box>
472 <Box flexShrink={0}>{isChanged ? stat(f.added, f.removed) : <Text color="suggestion" dimColor> read</Text>}</Box>
473 </Box>
474 )
475 })}
476 </Box>,
477 )}
478
479 {block(
480 'trail',
481 'subtle',
482 trailRows + 1,
483 <Box flexDirection="column">
484 {heading('Trail', pool.length > trailRows ? `${span(trailTop, pool.length, trailRows)} · newest first` : 'newest first')}
485 {pool.length === 0 ? <Text dimColor>· waiting for the first tool call…</Text> : ''}
486 {pool.slice(trailTop, trailTop + trailRows).map(a => {
487 const isFocus = a.id === focus?.id
488 const isQuiet = !isChange(a.kind) && !isFocus
489 // ▸, the kind padded to six and a space; the chips, a space and the age on the right.
490 const room = inner - 8 - statWidth(a.added, a.removed) - 1 - AGE
491 return (
492 <Box key={`t:${a.id}`} flexDirection="row" height={1} hover={{ backgroundColor: hover }}>
493 <Box flexGrow={1} flexShrink={1}>
494 <Button key={`trail:${a.id}`} plain onPress={() => update($, pinned, () => a.id)}>
495 {/* A subagent's move is marked ↳, so the main agent's stand apart. */}
496 <Text color={isFocus ? 'claude' : 'subtle'}>{isFocus ? '▸' : a.agentId !== null ? '↳' : ' '}</Text>
497 <Text bold={isChange(a.kind)} color={BADGE[a.kind].color}>{BADGE[a.kind].label.toLowerCase().padEnd(6)}</Text>{' '}
498 <Text dimColor={isQuiet} bold={isFocus}>
499 {fitActivity(a, room)}
500 </Text>
501 </Button>
502 </Box>
503 <Box flexShrink={0}>
504 {stat(a.added, a.removed)}
505 <Text dimColor>{` ${age(a)}`}</Text>
506 </Box>
507 </Box>
508 )
509 })}
510 </Box>,
511 )}
512
513 <Box flexDirection="row" height={1} gap={1} marginTop={0}>
514 <Button key="up" label="↑" hotkey="k" onPress={() => scrollCode($, -SCROLL_STEP)} />
515 <Button key="down" label="↓" hotkey="j" onPress={() => scrollCode($, SCROLL_STEP)} />
516 <Button key="top" label="top" hotkey="t" onPress={() => scrollCode($, -codeTotal)} />
517 <Button key="end" label="end" hotkey="e" onPress={() => scrollCode($, codeTotal)} />
518 {settings.nvimServer ? <Button key="nvim" label="nvim" hotkey="n" onPress={() => (focus ? mirror($, focus) : undefined)} /> : ''}
519 {/* The row's free right side: what the whole session has touched. */}
520 <Box flexGrow={1} flexShrink={1} justifyContent="flex-end">
521 {files.length > 0 ? (
522 <Text dimColor wrap="truncate-start">
523 {`${files.length} ${files.length === 1 ? 'file' : 'files'}${totalAdded + totalRemoved > 0 ? ` · +${totalAdded} −${totalRemoved}` : ''}`}
524 </Text>
525 ) : (
526 ''
527 )}
528 </Box>
529 </Box>
530 </Box>
531 )
532}
533
534export async function scrollPane($: EngineInterface, e: PaneScroll, next: (e: PaneScroll) => Promise<UiScrollResult>) {
535 const at = drawn
536 if (e.origin.kind !== 'person' || e.by === 0) return next(e)
537 const row = e.pointer?.row
538 const list = row === undefined ? undefined : drawnLists.find(l => row >= l.from && row < l.from + l.rows)
539 if (list) {
540 await scrollList($, list.name, Math.sign(e.by))
541 return next({ ...e, offset: Math.max(0, e.offset - e.by) })
542 }
543 if (at === null) return next(e)
544 if (row !== undefined && (row < at.codeTop || row >= at.codeTop + at.codeRows)) return next(e)
545 // A page key asks for the body's height: page the code window instead.
546 const by = row === undefined && Math.abs(e.by) >= e.bodyRows ? Math.sign(e.by) * at.codeRows : e.by
547 await scrollCode($, by)
548 return next({ ...e, offset: Math.max(0, e.offset - e.by) })
549}
550hooks/state.ts 55 lines1import type { ActivityKind } from '../types'
2
3export const PANE = 'follow'
4/** The dock width asked for beside a fullscreen transcript; the person's own drag wins. */
5export const SIDEBAR_COLUMNS = 64
6/** Docking is the surface's call: only the fullscreen layout from this width docks a pane. */
7export const DOCK_MIN_COLUMNS = 110
8export const MAX_ACTIVITIES = 80
9/** Only the newest few keep their file excerpt, so the state stays small. */
10export const MAX_VIEWS = 12
11export const READ_WINDOW = 120
12export const EDIT_CONTEXT = 12
13export const HUNK_CONTEXT = 3
14export const HUNK_CAP = 400
15export const OUTPUT_LINES = 400
16/** The age column: `12s`, `3m`, `held`, padded so a row's chips never move as it ticks. */
17export const AGE = 4
18/** The header's chip (`◎ SCROLLED`) and action (`⟲ follow live`) slots, each as wide as its widest content. */
19export const CHIP = 10
20export const ACTION = 17
21export const MAX_FILE_CHARS = 4_000_000
22export const TRAIL_ROWS = 6
23export const FILE_ROWS = 6
24/** Rows a wheel tick or j/k moves the code window. */
25export const SCROLL_STEP = 3
26
27
28export const BADGE: Record<ActivityKind, { label: string; color: string }> = {
29 read: { label: 'READ', color: 'suggestion' },
30 edit: { label: 'EDIT', color: 'warning' },
31 write: { label: 'WRITE', color: 'success' },
32 search: { label: 'SEARCH', color: 'ide' },
33 shell: { label: 'SHELL', color: 'bashBorder' },
34 plan: { label: 'PLAN', color: 'planMode' },
35 web: { label: 'WEB', color: 'permission' },
36 agent: { label: 'AGENT', color: 'claude' },
37 other: { label: 'TOOL', color: 'inactive' },
38}
39/** How long a finished call stays in the footer. */
40export const LINGER_MS = 8000
41export const LETTER: Partial<Record<ActivityKind, string>> = { read: 'R', edit: 'E', write: 'W' }
42
43/** What the session start learned and the options set; the modules read them from here, since an import cannot be assigned. */
44export const env = {
45 /** The project root paths are shown against; a shell `cd` does not move it. */
46 root: '',
47 home: '',
48}
49
50export const settings: { nvimServer: string; codeBackground: string; theme: string | null } = {
51 nvimServer: '',
52 codeBackground: 'auto',
53 theme: null,
54}
55hooks/diff.ts 143 lines1import { HUNK_CAP, HUNK_CONTEXT } from './state'
2
3type Op = { t: ' ' | '-' | '+'; line: string }
4
5/** Past this many cells the middle is diffed as one block, not aligned line by line. */
6const MAX_DIFF_CELLS = 4_000_000
7
8/** A line-by-line alignment (LCS) of `a` and `b`: each change's removals before its additions. */
9function align(a: string[], b: string[]): Op[] {
10 const n = a.length
11 const m = b.length
12 if (n * m > MAX_DIFF_CELLS) return [...a.map(line => ({ t: '-' as const, line })), ...b.map(line => ({ t: '+' as const, line }))]
13 // lcs[i * (m + 1) + j]: the longest common run of a[i..] and b[j..].
14 const lcs = new Uint32Array((n + 1) * (m + 1))
15 for (let i = n - 1; i >= 0; i--) {
16 for (let j = m - 1; j >= 0; j--) {
17 lcs[i * (m + 1) + j] =
18 a[i] === b[j] ? lcs[(i + 1) * (m + 1) + j + 1]! + 1 : Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!)
19 }
20 }
21 const ops: Op[] = []
22 let i = 0
23 let j = 0
24 while (i < n || j < m) {
25 if (i < n && j < m && a[i] === b[j]) {
26 ops.push({ t: ' ', line: a[i++]! })
27 j++
28 } else if (j >= m || (i < n && lcs[(i + 1) * (m + 1) + j]! >= lcs[i * (m + 1) + j + 1]!)) {
29 ops.push({ t: '-', line: a[i++]! })
30 } else {
31 ops.push({ t: '+', line: b[j++]! })
32 }
33 }
34 return ops
35}
36
37/**
38 * Unified-diff hunks of what differs between `before` and `after`, lines aligned
39 * as `git diff` does, with `context` unchanged lines around each change; hunks
40 * whose context would meet are merged. At most `cap` body lines are drawn, each
41 * header counting exactly the lines under it.
42 */
43export function buildHunk(
44 before: string[],
45 after: string[],
46 context = HUNK_CONTEXT,
47 cap = HUNK_CAP,
48): { hunk: string; line: number; isCut: boolean; added: number; removed: number } | null {
49 let head = 0
50 while (head < before.length && head < after.length && before[head] === after[head]) head++
51 let tail = 0
52 while (
53 tail < before.length - head &&
54 tail < after.length - head &&
55 before[before.length - 1 - tail] === after[after.length - 1 - tail]
56 ) {
57 tail++
58 }
59 if (head === before.length && head === after.length) return null
60
61 const lead = before.slice(Math.max(0, head - context), head)
62 const ops: Op[] = [
63 ...lead.map(line => ({ t: ' ' as const, line })),
64 ...align(before.slice(head, before.length - tail), after.slice(head, after.length - tail)),
65 ...before.slice(before.length - tail, before.length - tail + context).map(line => ({ t: ' ' as const, line })),
66 ]
67 const added = ops.filter(o => o.t === '+').length
68 const removed = ops.filter(o => o.t === '-').length
69
70 // Each op's line number on either side.
71 const first = head - lead.length + 1
72 const at: { old: number; new: number }[] = []
73 let oldNo = first
74 let newNo = first
75 for (const o of ops) {
76 at.push({ old: oldNo, new: newNo })
77 if (o.t !== '+') oldNo++
78 if (o.t !== '-') newNo++
79 }
80
81 // Spans of ops to draw: each change with its context, merged where they meet.
82 const spans: [number, number][] = []
83 ops.forEach((o, k) => {
84 if (o.t === ' ') return
85 const from = Math.max(0, k - context)
86 const to = Math.min(ops.length, k + context + 1)
87 const last = spans[spans.length - 1]
88 if (last && from <= last[1]) last[1] = Math.max(last[1], to)
89 else spans.push([from, to])
90 })
91
92 const hunks: string[] = []
93 let room = cap
94 let isCut = false
95 for (const [from, full] of spans) {
96 if (room <= 0) {
97 isCut = true
98 break
99 }
100 const to = Math.min(full, from + room)
101 if (to < full) isCut = true
102 const body = ops.slice(from, to)
103 room -= body.length
104 const oldCount = body.filter(o => o.t !== '+').length
105 const newCount = body.filter(o => o.t !== '-').length
106 const start = at[from]!
107 // An empty side names the line before it, as diff does.
108 const range = (line: number, count: number) => `${count === 0 ? line - 1 : line},${count}`
109 hunks.push(
110 [`@@ -${range(start.old, oldCount)} +${range(start.new, newCount)} @@`, ...body.map(o => `${o.t}${o.line}`)].join('\n'),
111 )
112 }
113
114 const firstChange = ops.findIndex(o => o.t !== ' ')
115 return { hunk: hunks.join('\n'), line: at[firstChange]!.new, isCut, added, removed }
116}
117
118export type DiffRow = { t: ' ' | '-' | '+'; text: string; old: number; new: number; hunk: number }
119
120/** A diff's body rows, each with the line numbers it stands at on either side. */
121export function diffRows(hunks: string): DiffRow[] {
122 const rows: DiffRow[] = []
123 let oldNo = 0
124 let newNo = 0
125 let hunk = -1
126 for (const row of hunks.split('\n')) {
127 const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(row)
128 if (header) {
129 hunk++
130 // An empty side names the line before it.
131 oldNo = Number(header[1]) + (header[2] === '0' ? 1 : 0)
132 newNo = Number(header[3]) + (header[4] === '0' ? 1 : 0)
133 continue
134 }
135 const t = row[0]
136 if (hunk < 0 || (t !== ' ' && t !== '-' && t !== '+')) continue
137 rows.push({ t, text: row.slice(1), old: oldNo, new: newNo, hunk })
138 if (t !== '+') oldNo++
139 if (t !== '-') newNo++
140 }
141 return rows
142}
143hooks/parse.ts 138 lines1import type { ToolCallResult } from 'claude-code'
2
3// ── reading a tool call: shell commands, search results ───────────────────
4
5export const isScratchPath = (path: string) => /\/scratchpad(\/|$)/.test(path) || /^\/tmp\/claude-/.test(path)
6
7const unquote = (s: string) => s.replace(/^(['"])(.*)\1$/, '$2')
8
9/**
10 * The command with heredoc bodies dropped and each quoted string swapped for a
11 * placeholder, so a `>` or `|` inside a message or a pattern is not read as shell.
12 */
13function mask(command: string): { masked: string; restore: (s: string) => string } {
14 const quoted: string[] = []
15 const masked = command
16 .replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2(?=\s|\)|$)/g, '$3')
17 .replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, q => `\u0001${quoted.push(q) - 1}\u0001`)
18 return { masked, restore: s => unquote(s.replace(/\u0001(\d+)\u0001/g, (_, k: string) => quoted[Number(k)] ?? '')) }
19}
20
21type ShellAccess = { kind: 'read' | 'write'; path: string; from?: number; to?: number; dir?: string }
22
23/**
24 * What a shell command does to a file, for the common read and write spellings
25 * (`cat`, `head`, `tail`, `sed -n A,Bp`, `nl`, `bat`; `>`, `>>`, `tee`). Null otherwise.
26 * `dir` is a leading `cd <dir>`, which a relative path is read against.
27 */
28export function parseShell(command: string): ShellAccess | null {
29 const found = matchShell(command)
30 // A path held in a shell variable cannot be followed.
31 if (!found || found.path === '' || /[$`*?\u0001]/.test(found.path)) return null
32 if (/^~?\//.test(found.path)) return found
33 const dir = shellDir(command)
34 if (dir === null) return null
35 return dir === undefined ? found : { ...found, dir }
36}
37
38/**
39 * The directory a command's relative paths are read against: a leading `cd
40 * <dir>`, undefined with no cd at all, null when a cd cannot be followed (a
41 * variable, `~`, `-`, or one further on) and a relative path is somewhere unknown.
42 */
43export function shellDir(command: string): string | null | undefined {
44 const { masked, restore } = mask(command)
45 const cds = masked.match(/(?:^|[;&|(]\s*)cd\b/g)?.length ?? 0
46 if (cds === 0) return undefined
47 const cd = /^\s*cd\s+([^\s;&|<>()]+)\s*(?:&&|;)/.exec(masked)
48 const dir = cd?.[1] ? restore(cd[1]) : ''
49 return cds === 1 && dir !== '' && dir !== '-' && !/[$`*?~\u0001]/.test(dir) ? dir : null
50}
51
52/** Path-like words anywhere in a command, heredoc bodies and quoted strings included: what a script may change. */
53export function namedPaths(command: string, limit = 12): string[] {
54 const found = new Set<string>()
55 const text = command.replace(/\b[a-z][\w+.-]*:\/\/\S+/gi, ' ')
56 // A quoted word (`open('notes.md')`) or one with a slash is likelier a file than `e.tool`: those first.
57 const rank = (path: string) => (/['"]$/.test(text.slice(0, text.indexOf(path))) || path.includes('/') ? 0 : 1)
58 const words: string[] = []
59 for (const m of text.matchAll(/(?<![\w$@{}.\/~-])(?:~\/|\.{1,2}\/|\/)?(?:[\w@+-][\w.@+-]*\/)*[\w@+-][\w@+-]*(?:\.[\w-]+)*\.[A-Za-z][\w-]{0,9}(?![\w\/(])/g)) {
60 const path = m[0]
61 if (/^\d|^-|\.(?:com|org|io|net)$/.test(path) || BINARY.test(path)) continue
62 words.push(path)
63 }
64 for (const path of words.sort((a, b) => rank(a) - rank(b))) {
65 found.add(path)
66 if (found.size >= limit) break
67 }
68 return [...found]
69}
70
71function matchShell(command: string): ShellAccess | null {
72 const { masked, restore } = mask(command)
73 // A file operand: never an option, never a redirect.
74 const file = `([^\\s<>|;&()-][^\\s<>|;&()]*)`
75
76 // A redirect target that names a path (a `/` or a `.`): `a > b` in code is a comparison.
77 const redirect = new RegExp(`(?:^|[^0-9&<>=-])>>?\\s*(?!=)${file}`).exec(masked)
78 const target = redirect?.[1] ? restore(redirect[1]) : ''
79 if (/[./]/.test(target) && !/^\/dev\//.test(target)) return { kind: 'write', path: target }
80 const tee = new RegExp(`\\btee\\s+(?:-a\\s+)?${file}`).exec(masked)
81 if (tee?.[1]) return { kind: 'write', path: restore(tee[1]) }
82
83 for (const raw of masked.split(/\s*(?:&&|\|\||;|\||\n)\s*/)) {
84 const seg = raw.trim().replace(/^cd\s+\S+\s*$/, '')
85 const sed = new RegExp(`^sed\\s+-n\\s+\\u0001?(\\d+)(?:,(\\d+))?p\\u0001?\\s+${file}$`).exec(
86 seg.replace(/\u0001(\d+)\u0001/g, (q, k: string) => {
87 const text = restore(q)
88 return /^\d+(,\d+)?p$/.test(text) ? `\u0001${text}\u0001` : `\u0001${k}\u0001`
89 }),
90 )
91 if (sed?.[1] && sed[3]) {
92 const from = Number(sed[1])
93 return { kind: 'read', path: restore(sed[3]), from, to: sed[2] ? Number(sed[2]) : from }
94 }
95 const headCmd = new RegExp(`^head(?:\\s+-n)?(?:\\s+-?(\\d+))?\\s+${file}$`).exec(seg)
96 if (headCmd?.[2]) return { kind: 'read', path: restore(headCmd[2]), from: 1, to: Number(headCmd[1] ?? 10) }
97 const cat = new RegExp(`^(?:cat|bat|batcat|nl|tail|less|more)((?:\\s+[^\\s<>|;&()]+)+)$`).exec(seg)
98 // Options and a count (`tail -n 20`) are no file; of several files, the first.
99 const operand = cat?.[1]?.trim().split(/\s+/).find(w => !w.startsWith('-') && !/^\d+$/.test(w))
100 if (operand) return { kind: 'read', path: restore(operand) }
101 }
102
103 return null
104}
105
106export const BINARY = /\.(png|jpe?g|gif|webp|bmp|ico|pdf|zip|gz|tgz|xz|jar|wasm|so|dylib|exe|bin|woff2?|ttf|otf|mp[34]|mov|ipynb)$/i
107
108/** The text a Grep result carries: a string, a `{ content | filenames }` object, or text blocks. */
109export function resultText(ran: ToolCallResult): string {
110 const r = ran.result as unknown
111 if (typeof r === 'string') return r
112 if (Array.isArray(r)) return r.map(b => (typeof b === 'string' ? b : String((b as { text?: string })?.text ?? ''))).join('\n')
113 if (r && typeof r === 'object') {
114 const o = r as { content?: unknown; filenames?: unknown }
115 if (typeof o.content === 'string' && o.content) return o.content
116 if (Array.isArray(o.filenames)) return o.filenames.join('\n')
117 }
118 return ''
119}
120
121/** The files a Grep matched, each with the first matching line (null where the result names only files). */
122export function grepFiles(text: string): { path: string; line: number | null }[] {
123 const hits = new Map<string, number | null>()
124 for (const raw of text.split('\n')) {
125 const row = raw.trimEnd()
126 const withLine = /^(.+?):(\d+):/.exec(row)
127 if (withLine) {
128 if (!hits.has(withLine[1]!)) hits.set(withLine[1]!, Number(withLine[2]))
129 continue
130 }
131 const counted = /^(.+):\d+$/.exec(row)
132 const path = counted ? counted[1]! : row
133 // A path, not a header (`Found 3 files`) or a context line.
134 if (/^(?:\/|\.\/|[\w@.-]+\/)\S*$/.test(path) && !/-\d+-/.test(path) && !hits.has(path)) hits.set(path, null)
135 }
136 return [...hits].map(([path, line]) => ({ path, line }))
137}
138hooks/text.ts 31 lines1// ── text helpers ───────────────────────────────────────────────────────────
2
3export function splitLines(text: string): string[] {
4 const lines = text.replace(/\r\n?/g, '\n').split('\n')
5 if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
6 return lines
7}
8
9/** The 1-based line `index` falls on. */
10export function lineOf(text: string, index: number): number {
11 let n = 1
12 for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n++
13 return n
14}
15
16const MAX_LINE = 1000
17
18/** A line safe to draw: control characters dropped, and a minified bundle's megabyte line cut, which blanks the pane. */
19export const clean = (line: string) => {
20 const text = line.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
21 return text.length > MAX_LINE ? `${text.slice(0, MAX_LINE)}…` : text
22}
23
24/** A label as one line: whitespace runs (a heredoc's newlines) folded, a leading `cd <dir> &&` dropped. */
25export function oneLine(label: string): string {
26 return label
27 .replace(/\s+/g, ' ')
28 .replace(/^cd\s+\S+\s*(?:&&|;)\s*/, '')
29 .trim()
30}
31types/index.d.ts 58 lines1export type ActivityKind = 'read' | 'edit' | 'write' | 'search' | 'shell' | 'plan' | 'web' | 'agent' | 'other'
2
3/** A window of a file's lines, `focusFrom..focusTo` (1-based, inclusive) marked in the gutter. */
4export type SourceView = {
5 mode: 'source'
6 path: string
7 startLine: number
8 lines: string[]
9 focusFrom: number
10 focusTo: number
11}
12
13/** One unified-diff hunk (header included) of what an edit or write changed. */
14export type DiffView = { mode: 'diff'; path: string; hunk: string; line: number; isCut: boolean; added: number; removed: number }
15
16/** What a shell command printed: stdout, then stderr, `isCut` when only the first lines are kept. */
17export type OutputView = { mode: 'output'; command: string; lines: string[]; isCut: boolean; isFailed: boolean }
18
19export type FileView = SourceView | DiffView | OutputView
20
21export type Activity = {
22 /** The tool_use_id. */
23 id: string
24 /** The loop it ran in: null for the main conversation. */
25 agentId: string | null
26 tool: string
27 kind: ActivityKind
28 /** Under a Claude scratchpad (or /tmp/claude-*) rather than the project. */
29 isScratch: boolean
30 path: string | null
31 line: number | null
32 endLine: number | null
33 /** One line saying what it does: a command, a pattern, a URL. */
34 label: string
35 /** Lines an edit or write added and removed, whole (not cut to what the hunk shows). */
36 added: number | null
37 removed: number | null
38 phase: 'running' | 'done' | 'error'
39 /** The edit or write was held for the machine owner to review: the file is unchanged. */
40 isHeld?: boolean
41 at: number
42 view: FileView | null
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 follow: {
48 activities: Activity[]
49 /** The activity the person pinned the pane to; null follows live. */
50 pinned: string | null
51 /** Where the person scrolled the code window, for the activity it showed; another activity follows again. */
52 scroll: { id: string; top: number } | null
53 /** How far the person scrolled the Files and Trail lists, in rows from the newest. */
54 lists: { files: number; trail: number }
55 }
56 }
57}
58