SLOPSHOPPER

follow

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

newpanespinnerguardcommandstatus
v0.7.1no licenseupdated 2026-10-25tidefox/claude-plugins/plugins/follow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · follow
│ ┃ Follow ✕ › fix the failing auth test and add an audit log call │ ┃ Follow main agent● LIVE [ pi │ ┃ ⏺ Read(src/auth.ts) │ ┃ ┃ Read .env:1-2 0s ⎿ Read 6 lines │ ┃ ┃ $ cat .env ⏺ Update(src/auth.ts) │ ┃ ┃ │1 DATABASE_URL=postgres://app:hunter2@db ⎿ Added 2 lines, removed 1 line │ ┃ ┃ │· DATABASE_URL=postgres://app:hunter2@db ⏺ Bash(bun test) │ ┃ ┃ │2 STRIPE_SECRET_KEY=sk_live_51Hxxxxxxxxx ⎿ 3 pass, 1 fail │ ┃ ┃ │· STRIPE_SECRET_KEY=sk_live_51Hxxxxxxxxx │ ┃ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ┃ │ ┃ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ┃ │ ┃ ┃ › /follow │ ┃ ┃ ⎿ follow: Following the agent in the sidebar. │ ┃ ┃ │ ┃ ┃ │ ┃ ┃ │ ┃ ┃ │ ┃ ┃ │ ┃ │ ┃ ┃ Files 1–3 of 4 │ ┃ ┃ │ ┃ ┃ file:/work/app/.env read │ ┃ ┃ file:/work/app/src/cache.ts │ ┃ file:/work/app/src/audit.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits read .env:1-2:1

Draws

Pane · Follow
Follow main agent ● LIVE [ pin ] ┃ Read .env:1-2 0s ┃ $ cat .env ┃ │1 DATABASE_URL=postgres://app:hunter2@db.internal:5432/a ┃ │· DATABASE_URL=postgres://app:hunter2@db.internal:5432/a ┃ │2 STRIPE_SECRET_KEY=sk_live_51Hxxxxxxxxxxxxxxxxxxxxxxxx ┃ │· STRIPE_SECRET_KEY=sk_live_51Hxxxxxxxxxxxxxxxxxxxxxxxx ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ Files 1–3 of 4 ┃ ┃ file:/work/app/.env read ┃ file:/work/app/src/cache.ts file:/work/app/src/audit.ts ┃ Trail 1–3 of 9 · newest first ┃ ┃ trail:toolu_09 0s ┃ trail:toolu_08 0s trail:toolu_07 0s [ ↑ ] [ ↓ ] [ top ] [ end ]
README

follow

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.

  • Captures every tool call: 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.
  • Footer chips: at the right of the footer, beside the statusline, what the main agent is doing now (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.
  • Scratchpad files are tagged scratch (and ✎ in Files) so you can tell them apart from project files.
  • Pane: an OpenCode-style fixed layout in Claude's orange: a header with a ● 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.
  • Scrollable code view: the code window shows the whole file for a read (the lines it read marked │, 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.
  • Fixed scale: nothing in the pane moves as it updates: the scrollbar's column, the scroll percent, the header chip and button, the ages and the bottom buttons keep their cells whether or not they have anything to show, and the footer chips always draw both rows.
  • Follow: tracks every agent's activity, the main agent's and each subagent's, newest first; the header names the agent whose move is on show (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.
  • Code view background: a shade dimmer than the terminal (by theme) so the code window stands apart. Set codeBackground in /config to none, or to any color such as #1e1e2e.
  • Neovim mirror (optional): set nvimServer in /config to a nvim --listen address, and each focus change opens the file at that line in your Neovim too.

Sidebar

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

Commands

CommandDoes
/followopen the pane
/follow liveunpin and follow live
/follow clearclear the trail (a /clear does too)

Install

In a Claude Code terminal session:

/plugin install follow --marketplace tidefox/claude-plugins

Answer y to add the marketplace, then pick a scope.

Source 10 files
hooks/register.tsx 188 lines
1import { 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}
188
hooks/capture.ts 354 lines
1import 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}
354
hooks/labels.ts 24 lines
1import 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))
24
hooks/layout.ts 153 lines
1// ── 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}
153
hooks/pane.tsx 550 lines
1import { 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}
550
hooks/state.ts 55 lines
1import 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}
55
hooks/diff.ts 143 lines
1import { 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}
143
hooks/parse.ts 138 lines
1import 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}
138
hooks/text.ts 31 lines
1// ── 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}
31
types/index.d.ts 58 lines
1export 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