/changes: the session's edits grouped by concept, then files, then the exact code; point at lines and ask Claude about them or have it change them

Browse concept groups, files and symbols. Select code in the viewer to ask a question or request a change. Branch scope uses Git diffs. Shell edit capture is heuristic and bounded; it is not a complete filesystem audit. Fallback group labels use Haiku.
claude plugin install changes --marketplace sruthik27/claude-foundry
Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.
/changes
/changes branch
MIT licensed. Maintained by Sruthik Issac.
hooks/register.tsx 583 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Edit, GroupKind, Hunk, Model, Thread } from '../types'
5import {
6 EMPTY, answerOf, applyLabels, assign, isFallback, firstChanged, marks, readGroupTags, record, rows, stripGroupTags, symbolAt, threadPrompt,
7} from './model.js'
8import type { ViewerLine, ViewerProps } from './viewer.js'
9import { GIT_ENV, NOLOCK, branchModel, parseDiff, parseLog } from './branch.js'
10import { MAX_BYTES, MAX_FILES, candidates, diffHunks } from './shell.js'
11import { describeEdits, labelPrompt, parseLabels } from './label.js'
12
13const PANE = 'changes'
14const TITLE = 'Changes'
15const model = atom({ plugin: 'changes', key: 'model' } as const, EMPTY)
16
17const KIND: Record<GroupKind, { icon: string; color: string; label: string }> = {
18 feature: { icon: '✦', color: '#a78bfa', label: 'new' },
19 behaviour: { icon: '◆', color: '#38bdf8', label: 'behaviour' },
20 fix: { icon: '✚', color: '#34d399', label: 'fix' },
21 refactor: { icon: '↻', color: '#f472b6', label: 'refactor' },
22 test: { icon: '✓', color: '#facc15', label: 'tests' },
23 config: { icon: '⚙', color: '#94a3b8', label: 'config' },
24 removal: { icon: '−', color: '#f87171', label: 'removal' },
25 docs: { icon: '¶', color: '#c4b5fd', label: 'docs' },
26}
27
28const GUIDE = `# Change grouping (changes mod: display only)
29At the end of every turn in which you edited files, add one self-closing tag per concept you worked on, on its own line:
30<change-group id="short-stable-slug" kind="feature|behaviour|fix|refactor|test|config|removal|docs" title="what changed, in plain words (<= 8 words)" summary="1-2 sentences: what it does now and why, for a reader who did not watch you"/>
31Reuse an existing id (listed below) when the edits continue that concept, so related work stays together; mention the file or function names in the summary so edits can be matched. This only labels your work for the user's review pane: never change how you work because of it.`
32
33/** Per-process session state the hooks share (not drawn, so not in $.state). */
34const S: {
35 turn: number
36 task: string | undefined
37 /** The thread the running turn is answering, and whether it is ask-only. */
38 live: { id: string; mode: 'ask' | 'change' } | null
39 queue: string[]
40 draft: { mode: 'ask' | 'change'; path: string; start: number; end: number } | null
41 cwd: string
42 home: string
43 /** The prompt that started the running turn: what the labeller reads as intent. */
44 ask: string
45 /** The branch view, built on demand (git), and why it could not be when it could not. */
46 branch: { model: Model; base: string; root: string } | null
47 branchNote: string
48} = { turn: 0, task: undefined, live: null, queue: [], draft: null, cwd: '', home: '', ask: '', branch: null, branchNote: '' }
49
50const rel = (p: string) => (S.cwd && p.startsWith(S.cwd + '/') ? p.slice(S.cwd.length + 1) : p)
51
52export const register: Register = on => {
53 S.turn = 0; S.task = undefined; S.live = null; S.queue = []; S.draft = null; S.branch = null; S.branchNote = ''
54
55 on('session.start', async ($, e, next) => {
56 // per session: a resume or /clear in the same process must not inherit a pending ask (which
57 // would keep refusing edits), a queue, a draft or another session's branch view
58 S.turn = 0; S.task = undefined; S.live = null; S.queue = []; S.draft = null; S.branch = null; S.branchNote = ''
59 const r = await next(e)
60 S.cwd = e.cwd
61 S.home = (await $.env.get('HOME')) ?? ''
62 await $.command.register({ name: 'changes', description: 'Review this session\'s changes by concept; point at code to ask or change', argumentHint: '[branch]' })
63 let saved = (await $.store.get(`model:${await $.session.id()}`)) as Model | undefined
64 // A resume that came back under a new id: carry over the session it continued from.
65 if (!saved) { const prev = await predecessorOf($, e.cwd); if (prev) saved = (await $.store.get(`model:${prev}`)) as Model | undefined }
66 if (saved && (await read($, model)).edits.length === 0) {
67 await update($, model, () => ({ ...saved, nav: { ...saved.nav, level: 'groups' as const } }))
68 S.turn = Math.max(0, ...saved.edits.map(x => x.turn), ...saved.threads.map(x => x.turn))
69 }
70 return r
71 })
72
73 on('classic.SessionStart', async ($, e, next) => {
74 const r = await next(e)
75 if (!['clear', 'resume', 'fork'].includes(e.source)) return r
76 S.turn = 0; S.task = undefined; S.live = null; S.queue = []; S.draft = null; S.branch = null; S.branchNote = ''; S.ask = ''
77 S.cwd = e.cwd ?? S.cwd
78 let saved = e.source === 'clear' ? undefined : await $.store.get(`model:${await $.session.id()}`) as Model | undefined
79 if (!saved && e.source !== 'clear') { const prev = await predecessorOf($, S.cwd); if (prev) saved = await $.store.get(`model:${prev}`) as Model | undefined }
80 await update($, model, () => saved ? { ...saved, nav: { ...EMPTY.nav } } : EMPTY)
81 if (saved) S.turn = Math.max(0, ...saved.edits.map(x => x.turn), ...saved.threads.map(x => x.turn))
82 return r
83 })
84
85 on('prompt.compose', async ($, e, next) => {
86 const r = await next(e)
87 const m = await read($, model)
88 const ids = m.groups.slice(-12).map(g => `- ${g.id}: ${g.title}`).join('\n')
89 const text = ids ? `${GUIDE}\n\nExisting groups (id: title):\n${ids}` : GUIDE
90 return { ...r, sections: [...r.sections, { id: 'changes:guide', text, scope: 'session' }] }
91 })
92
93 on('turn.start', async ($, e, next) => {
94 // a subagent's turns are not the main loop's: counting them would orphan the main turn's edits
95 if (!(e as { agentId?: string }).agentId) { S.turn += 1; S.ask = String((e as { text?: string }).text ?? '').slice(0, 2000) }
96 return next(e)
97 })
98
99 // A short nudge with each prompt: Claude labels its own work more often, so fewer fallback calls.
100 on('prompt.submit', async ($, e, next) => {
101 const nudge = 'Reminder from the changes mod: if this turn edits files, end your reply with the <change-group .../> tag(s) your system prompt describes (reuse an existing id when the work continues it).'
102 return next({ ...e, context: [...(e.context ?? []), nudge] })
103 })
104
105 // ---- observe the task tools for the fallback group name ----
106 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
107 const r = await next(e)
108 if (e.status === 'in_progress' && (e.activeForm || e.subject)) S.task = e.activeForm ?? e.subject
109 return r
110 })
111 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
112 const r = await next(e)
113 const t = e.todos.find(x => x.status === 'in_progress')
114 if (t) S.task = t.content
115 return r
116 })
117
118 // ---- record every edit (read-only; ask turns refuse them) ----
119 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
120 if (S.live?.mode === 'ask' && !e.agentId) {
121 return { deny: 'This turn answers a question from the Changes pane: explain only, do not edit files. If a change is needed, say so and the user can ask for it.' }
122 }
123 const r = await next(e)
124 if (r.deny !== undefined || r.isError || !r.result) return r
125 const res = r.result as { filePath: string; structuredPatch: Hunk[]; type?: string; originalFile?: string | null; content?: string }
126 const hunks = (res.structuredPatch ?? []).slice(0, 40)
127 const created = res.type === 'create'
128 if (hunks.length === 0 && !created) return r
129 let lines: string[] = []
130 try { lines = (await $.fs.read(res.filePath)).split('\n') } catch { /* gone again */ }
131 const effective = created && hunks.length === 0 && res.content !== undefined
132 ? [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: res.content.split('\n').length, lines: res.content.split('\n').slice(0, 400).map(l => '+' + l) }]
133 : hunks
134 const edit: Edit = {
135 id: `${S.turn}-${Math.random().toString(36).slice(2, 8)}`,
136 turn: S.turn, path: res.filePath, hunks: effective,
137 symbols: effective.map(h => symbolAt(lines, firstChanged(h))),
138 ...(created ? { created: true } : {}),
139 ...(S.task ? { task: S.task } : {}),
140 }
141 await update($, model, m => record(m, edit))
142 return r
143 })
144
145 // ---- shell edits: a Bash call has no patch, so the files it names are compared before/after ----
146 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
147 const paths = candidates(e.command, S.cwd, S.home).slice(0, MAX_FILES * 2)
148 const before = new Map<string, string | null>()
149 for (const p of paths) {
150 if (before.size >= MAX_FILES) break
151 before.set(p, await readSmall($, p))
152 }
153 const r = await next(e)
154 if (r.deny !== undefined || before.size === 0) return r
155 const made: Edit[] = []
156 for (const [p, was] of before) {
157 const now = await readSmall($, p)
158 const deleted = now === null && was !== null && !(await $.fs.exists(p))
159 if ((now === null && !deleted) || now === was) continue
160 const hunks = diffHunks(was ?? '', now ?? '')
161 if (hunks.length === 0) continue
162 const lines = (now ?? was ?? '').split('\n')
163 made.push({
164 id: `${S.turn}-${Math.random().toString(36).slice(2, 8)}`,
165 turn: S.turn, path: p, hunks,
166 symbols: hunks.map(h => symbolAt(lines, firstChanged(h))),
167 ...(was === null ? { created: true } : {}),
168 ...(deleted ? { deleted: true } : {}),
169 ...(S.task ? { task: S.task } : {}),
170 })
171 }
172 if (made.length > 0) await update($, model, m => made.reduce(record, m))
173 return r
174 })
175
176 // ---- end of turn: group, answer threads, follow the next ask ----
177 on('turn.complete', async ($, e, next) => {
178 const r = await next(e)
179 if (e.agentId) return r
180 const tags = readGroupTags(e.answer)
181 const was = S.live
182 S.live = null
183 const m = await update($, model, cur => {
184 let x = assign(cur, S.turn, tags)
185 if (was) {
186 x = {
187 ...x,
188 threads: x.threads.map(t => (t.id === was.id ? { ...t, status: 'answered', answer: answerOf(e.answer) || (e.isAborted ? '(interrupted)' : '(no answer)') } : t)),
189 }
190 if (was.mode === 'change') {
191 // the change lands where it was asked: the viewer follows it
192 const t = x.threads.find(y => y.id === was.id)
193 const made = x.edits.filter(y => y.turn === S.turn)
194 const same = made.find(y => t && y.path.endsWith(t.path))
195 const target = same ?? made[0]
196 if (target) {
197 const g = target.group ?? x.edits.find(y => y.path === target.path)?.group
198 x = { ...x, nav: { ...x.nav, level: 'code' as const, group: g, file: target.path }, focusSeq: x.focusSeq + 1 }
199 }
200 }
201 }
202 return x
203 })
204 let saved = m
205 // Edits still in fallback groups ("Other changes"): this turn's first, then up to two older
206 // turns (sessions from before the labeller), one small call per turn, oldest first.
207 const unlabelled = [...new Set(m.edits.filter(x => m.groups.some(g => g.id === x.group && isFallback(g))).map(x => x.turn))]
208 const todo = [...(unlabelled.includes(S.turn) && tags.length === 0 ? [S.turn] : []), ...unlabelled.filter(t => t !== S.turn).sort((a, b) => a - b).slice(0, 2)]
209 for (const t of todo) {
210 try {
211 const cur = await read($, model)
212 const turnEdits = cur.edits.filter(x => x.turn === t)
213 const known = cur.groups.filter(g => !isFallback(g))
214 const reply = await $.model.complete({
215 model: 'haiku', effort: 'low', maxTokens: 600, timeoutMs: 30000,
216 prompt: labelPrompt(t === S.turn ? S.ask : '', describeEdits(turnEdits, rel), known),
217 })
218 if (reply.isAnswered) {
219 const labels = parseLabels(reply.text, [...new Set(turnEdits.map(x => x.path))], new Set(known.map(g => g.id)), rel)
220 if (labels.length > 0) saved = await update($, model, x => applyLabels(x, t, labels))
221 }
222 } catch { /* the fallback group stays: still shown, just unnamed */ }
223 }
224 await persist($, saved)
225 if (was) {
226 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
227 if (!isOpen) void $.ui.open({ id: PANE, title: TITLE })
228 if (was.mode === 'change') $.ui.toast('Changes: the edit is open in the pane')
229 }
230 const nextId = S.queue.shift()
231 if (nextId) void sendThread($, nextId)
232 return r
233 })
234
235 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
236 if (!e.props.text.includes('<change-group')) return next(e)
237 return next({ ...e, props: { ...e.props, text: stripGroupTags(e.props.text) } })
238 })
239
240 // ---- the command ----
241 on('command.run', { command: 'changes' }, async ($, e) => {
242 const arg = e.args.trim()
243 const open = (await $.ui.panes()).some(p => p.id === PANE)
244 if (open && arg === '') { await $.ui.close({ id: PANE }); return {} }
245 if (arg === 'branch') await loadBranch($)
246 if (arg === 'session') await update($, model, m => ({ ...m, nav: { ...m.nav, scope: 'session' as const, level: 'groups' as const } }))
247 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
248 return {}
249 })
250
251 // ---- the viewer's posts ----
252 on('ui.message', { requestId: PANE }, async ($, e) => {
253 const d = e.data as { type: string; mode?: 'ask' | 'change'; start?: number; end?: number; step?: number }
254 const sm = await read($, model)
255 const m: Model = sm.nav.scope === 'branch' && S.branch ? { ...S.branch.model, nav: sm.nav, reviewed: sm.reviewed, threads: sm.threads } : sm
256 if (d.type === 'back') { await update($, model, x => ({ ...x, nav: { ...x.nav, level: 'files' as const } })); return {} }
257 if (d.type === 'reviewed' && m.nav.group && m.nav.file) {
258 const k = `${m.nav.group}|${m.nav.file}`
259 const n = await update($, model, x => ({ ...x, reviewed: x.reviewed.includes(k) ? x.reviewed.filter(y => y !== k) : [...x.reviewed, k] }))
260 await persist($, n)
261 return {}
262 }
263 if (d.type === 'file' && m.nav.group) {
264 const files = rows(m).find(g => g.id === m.nav.group)?.files ?? []
265 const i = files.findIndex(f => f.path === m.nav.file)
266 const f = files[(i + (d.step ?? 1) + files.length) % files.length]
267 if (f) await update($, model, x => ({ ...x, nav: { ...x.nav, file: f.path }, focusSeq: x.focusSeq + 1 }))
268 return {}
269 }
270 if (d.type === 'ask' && m.nav.file && d.mode) {
271 S.draft = { mode: d.mode, path: m.nav.file, start: d.start ?? 1, end: d.end ?? d.start ?? 1 }
272 void $.ui.invalidate('ui.render')
273 return {}
274 }
275 return {}
276 })
277
278 // ---- drawing ----
279 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
280 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
281 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
282 const sm = await read($, model)
283 const isBranch = sm.nav.scope === 'branch' && S.branch !== null
284 const m: Model = isBranch ? { ...S.branch!.model, nav: sm.nav, focusSeq: sm.focusSeq, threads: sm.threads, reviewed: sm.reviewed } : sm
285 const w = Math.max(30, e.props.bodyColumns - 1)
286 const groups = rows(m)
287 const toggleScope = async () => {
288 if (isBranch) await update($, model, x => ({ ...x, nav: { ...x.nav, scope: 'session' as const, level: 'groups' as const } }))
289 else await loadBranch($)
290 }
291 const pending = m.threads.filter(t => t.status === 'pending').length
292
293 const header = (
294 <Box flexDirection="row" justifyContent="space-between">
295 <Text bold color="#e5e7eb">{isBranch ? 'Branch changes' : 'What changed'}
296 <Text dimColor>{` · ${isBranch ? `since ${S.branch!.base}` : 'this session'} · ${groups.length} concept${groups.length === 1 ? '' : 's'}`}</Text>
297 </Text>
298 <Box flexDirection="row" columnGap={2}>
299 {pending > 0 && <Text color="#fbbf24">{`⧗ ${pending} queued`}</Text>}
300 <Button key="scope" label={isBranch ? 'session' : 'branch'} plain hotkey="s" onPress={toggleScope} />
301 </Box>
302 </Box>
303 )
304
305 if (groups.length === 0) {
306 return (
307 <Box flexDirection="column" gap={1}>
308 {header}
309 <Text dimColor wrap="wrap">{isBranch ? 'No changes against the base branch.' : 'No edits yet this session. As Claude changes files, they appear here grouped by what they are for. Press s for the whole branch.'}</Text>
310 {S.branchNote !== '' && <Text color="#fbbf24" wrap="wrap">{S.branchNote}</Text>}
311 </Box>
312 )
313 }
314
315 // ---------- level 1: concepts ----------
316 if (m.nav.level === 'groups' || !m.nav.group) {
317 const total = groups.reduce((s, g) => s + g.files.length, 0)
318 const done = groups.reduce((s, g) => s + g.reviewedFiles, 0)
319 return (
320 <Box flexDirection="column" gap={1}>
321 {header}
322 {groups.map((g, i) => {
323 const k = KIND[g.kind]
324 const allDone = g.reviewedFiles === g.files.length
325 return (
326 <Box key={`g-${g.id}`} flexDirection="column">
327 <Box flexDirection="row" columnGap={1}>
328 <Text color={k.color} bold>{k.icon}</Text>
329 <Button key={`open-${g.id}`} label={g.title.slice(0, Math.max(12, w - 26))} plain {...(i < 9 ? { hotkey: String(i + 1) } : {})} {...(i === 0 ? { autoFocus: true as const } : {})}
330 onPress={() => update($, model, x => ({ ...x, nav: { ...x.nav, level: 'files' as const, group: g.id } }))} />
331 </Box>
332 <Box flexDirection="row" columnGap={1} marginLeft={2}>
333 <Text color={k.color} dimColor>{k.label}</Text>
334 <Text dimColor>· {g.files.length} file{g.files.length === 1 ? '' : 's'}</Text>
335 <Text color="#34d399">+{g.add}</Text>
336 {g.del > 0 && <Text color="#f87171">−{g.del}</Text>}
337 {allDone ? <Text color="#34d399">✔</Text> : g.reviewedFiles > 0 ? <Text dimColor>{g.reviewedFiles}/{g.files.length} reviewed</Text> : null}
338 </Box>
339 {g.summary !== '' && <Box marginLeft={2}><Text dimColor wrap="wrap">{g.summary}</Text></Box>}
340 </Box>
341 )
342 })}
343 {S.branchNote !== '' && <Text color="#fbbf24" wrap="wrap">{S.branchNote}</Text>}
344 <Text dimColor>{`${done}/${total} files reviewed · 1-9 or Enter open · s ${isBranch ? 'session' : 'branch'} · Esc closes`}</Text>
345 </Box>
346 )
347 }
348
349 const g = groups.find(x => x.id === m.nav.group) ?? groups[0]!
350 const k = KIND[g.kind]
351 const back = (to: 'groups' | 'files') => update($, model, x => ({ ...x, nav: { ...x.nav, level: to } } as Model))
352
353 // ---------- level 2: files of a concept ----------
354 if (m.nav.level === 'files' || !m.nav.file) {
355 return (
356 <Box flexDirection="column" gap={1}>
357 <Box flexDirection="row" columnGap={1}>
358 <Button key="back" label="‹ All changes" plain hotkey="b" onPress={() => back('groups')} />
359 </Box>
360 <Box flexDirection="column">
361 <Text color={k.color} bold>{k.icon} {g.title}</Text>
362 {g.summary !== '' && <Text wrap="wrap">{g.summary}</Text>}
363 </Box>
364 {g.files.map((f, i) => {
365 const done = m.reviewed.includes(`${g.id}|${f.path}`)
366 return (
367 <Box key={`f-${f.path}`} flexDirection="column">
368 <Box flexDirection="row" columnGap={1}>
369 <Text color={done ? '#34d399' : '#5b6170'}>{done ? '✔' : '○'}</Text>
370 <Button key={`file-${i}`} label={rel(f.path).slice(-Math.max(12, w - 22))} plain {...(i < 9 ? { hotkey: String(i + 1) } : {})} {...(i === 0 ? { autoFocus: true as const } : {})}
371 onPress={() => update($, model, x => ({ ...x, nav: { ...x.nav, level: 'code' as const, file: f.path }, focusSeq: x.focusSeq + 1 }))} />
372 <Text color="#34d399">+{f.add}</Text>
373 {f.del > 0 && <Text color="#f87171">−{f.del}</Text>}
374 {f.deleted && <Text color="#f38ba8">deleted</Text>}
375 {f.created && !f.deleted && <Text color="#a78bfa">new</Text>}
376 </Box>
377 {f.symbols.length > 0 && (
378 <Box marginLeft={2}><Text color="#a78bfa" dimColor wrap="truncate-end">ƒ {f.symbols.slice(0, 4).join(' · ')}{f.symbols.length > 4 ? ` +${f.symbols.length - 4}` : ''}</Text></Box>
379 )}
380 </Box>
381 )
382 })}
383 </Box>
384 )
385 }
386
387 // ---------- level 3: the pointable code ----------
388 const path = m.nav.file
389 const file = g.files.find(f => f.path === path)
390 let text = ''
391 try { text = await $.fs.read(path) } catch { text = '' }
392 const src = text.split('\n')
393 const allForFile = m.edits.filter(x => x.path === path)
394 const mk = marks(file?.edits ?? [], allForFile)
395 const lines: ViewerLine[] = src.map((t, i) => ({
396 n: i + 1, text: t,
397 ...(mk.added.has(i + 1) ? { mark: 'add' as const } : {}),
398 ...(mk.removedAt.has(i + 1) ? { removed: mk.removedAt.get(i + 1)! } : {}),
399 }))
400 const latest = allForFile.at(-1)
401 if (latest?.deleted) {
402 const removed = latest.hunks.flatMap(h => h.lines.filter(l => l.startsWith('-')).map(l => l.slice(1)))
403 lines.splice(0, lines.length, { n: 1, text: '', removed })
404 }
405 // keep the viewer light: the marked regions with generous context, whole small files
406 const keep = new Set<number>()
407 const ctx = 12
408 if (src.length <= 400) src.forEach((_, i) => keep.add(i))
409 else for (const l of lines) if (l.mark || l.removed) for (let j = l.n - 1 - ctx; j <= l.n - 1 + ctx; j++) if (j >= 0 && j < src.length) keep.add(j)
410 const shown = lines.filter((_, i) => keep.has(i)).slice(0, 1200)
411 const firstMark = shown.find(l => l.mark || l.removed)?.n ?? 1
412 const symbols = symbolsOf(src)
413 const threads = m.threads.filter(t => t.path === rel(path))
414 const viewerProps: ViewerProps = {
415 path: rel(path), lines: shown, cursor: firstMark, jump: m.focusSeq, symbols,
416 threads: threads.map(t => ({ start: t.start, end: t.end, mode: t.mode, text: t.text, ...(t.answer ? { answer: t.answer } : {}), pending: t.status === 'pending' })),
417 height: Math.max(6, (e.props.scroll?.bodyRows ?? 30) - (S.draft ? 7 : 5)),
418 reviewed: m.reviewed.includes(`${g.id}|${path}`),
419 }
420 const fileIdx = g.files.findIndex(f => f.path === path)
421
422 return (
423 <Box flexDirection="column">
424 <Box flexDirection="row" columnGap={1}>
425 <Button key="back-files" label="‹" plain hotkey="b" onPress={() => back('files')} />
426 <Text color={k.color}>{k.icon}</Text>
427 <Text bold wrap="truncate-end">{rel(path)}</Text>
428 <Text dimColor>{`${fileIdx + 1}/${g.files.length}`}</Text>
429 </Box>
430 <Client key="viewer" module="./viewer.tsx" props={viewerProps} flexGrow={1} />
431 {S.draft ? (
432 <Box flexDirection="column" borderStyle="round" borderColor={S.draft.mode === 'ask' ? '#60a5fa' : '#fbbf24'}>
433 <Text color={S.draft.mode === 'ask' ? '#60a5fa' : '#fbbf24'} bold>
434 {`${S.draft.mode === 'ask' ? '❓ Ask about' : '✎ Change'} ${rel(S.draft.path)}:${S.draft.start}${S.draft.end !== S.draft.start ? `-${S.draft.end}` : ''}`}
435 <Text dimColor> (Enter sends · Esc cancels)</Text>
436 </Text>
437 <Input key="ask-input" placeholder={S.draft.mode === 'ask' ? 'What do you want to know about these lines?' : 'What should change here?'} value="" autoFocus
438 onSubmit={async (v: string) => {
439 const d = S.draft
440 S.draft = null
441 if (!d || !v.trim()) { void $.ui.invalidate('ui.render'); return }
442 const t: Thread = { id: Math.random().toString(36).slice(2, 9), path: rel(d.path), start: d.start, end: d.end, mode: d.mode, text: v.trim(), turn: S.turn, status: 'pending' }
443 await update($, model, x => ({ ...x, threads: [...x.threads, t].slice(-200) }))
444 if (S.live) { S.queue.push(t.id); $.ui.toast('Queued: it runs after the current turn') }
445 else void sendThread($, t.id)
446 }} />
447 </Box>
448 ) : (
449 <Text dimColor wrap="truncate-end">↑↓/jk move · v select · f function · a ask · c change · n/p next change · [ ] file · r reviewed · t note · x removed · ‹ b back</Text>
450 )}
451 </Box>
452 )
453 })
454
455 // Esc in the prompt bar cancels the draft rather than leaving the pane.
456 on('ui.close', { id: PANE }, async ($, e, next) => {
457 if (S.draft && e.origin.kind === 'person') { S.draft = null; void $.ui.invalidate('ui.render'); return { deny: 'cancelled the ask' } }
458 return next(e)
459 })
460
461}
462
463/** A text file's contents, or null when it is missing, too big, binary or unreadable. */
464async function readSmall($: import('claude-code').EngineInterface, path: string): Promise<string | null> {
465 try {
466 const t = await $.fs.read(path)
467 if (t.length > MAX_BYTES || t.includes('\u0000')) return null
468 return t
469 } catch {
470 return null
471 }
472}
473
474/** Reads the branch's changes with git and switches the pane to them. */
475async function loadBranch($: import('claude-code').EngineInterface): Promise<void> {
476 const git = (args: string[]) => $.process.run(['git', NOLOCK, ...args], { cwd: S.cwd, env: { ...GIT_ENV }, timeoutMs: 15000 })
477 S.branchNote = ''
478 const top = await git(['rev-parse', '--show-toplevel'])
479 if (top.exitCode !== 0) { S.branchNote = 'Not a git repository: branch view needs git.'; void $.ui.invalidate('ui.render'); return }
480 const root = top.stdout.trim()
481 let base = ''
482 const sym = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
483 const named = sym.exitCode === 0 ? sym.stdout.trim() : ''
484 for (const cand of [named, 'origin/main', 'origin/master', 'main', 'master'].filter(Boolean)) {
485 if ((await git(['rev-parse', '--verify', '--quiet', cand])).exitCode === 0) { base = cand; break }
486 }
487 if (!base) { S.branchNote = 'No base branch found (origin/HEAD, main or master).'; void $.ui.invalidate('ui.render'); return }
488 const mb = await git(['merge-base', 'HEAD', base])
489 const from = mb.exitCode === 0 ? mb.stdout.trim() : base
490 const diff = await git(['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '-U3', from])
491 const log = await git(['log', '--name-only', '--format=%x1e%h%x1f%s', `${from}..HEAD`])
492 const wip = await git(['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--name-only', 'HEAD'])
493 const files = parseDiff(diff.stdout)
494 const cache = new Map<string, string[]>()
495 for (const p of [...files.keys()].slice(0, 200)) {
496 try { cache.set(`${root}/${p}`, (await $.fs.read(`${root}/${p}`)).split('\n')) } catch { cache.set(`${root}/${p}`, []) }
497 }
498 const session = await read($, model)
499 S.branch = { model: branchModel(root, files, parseLog(log.stdout), new Set(wip.stdout.split('\n').filter(Boolean)), session, a => cache.get(a) ?? []), base: base.replace(/^origin\//, ''), root }
500 if (diff.isStdoutTruncated) S.branchNote = 'The branch diff is very large; it was cut.'
501 await update($, model, x => ({ ...x, nav: { ...x.nav, scope: 'branch' as const, level: 'groups' as const } }))
502}
503
504/** Sends one thread to Claude as a visible prompt with the code attached. */
505async function sendThread($: import('claude-code').EngineInterface, id: string): Promise<void> {
506 const m = await read($, model)
507 const t = m.threads.find(x => x.id === id)
508 if (!t) return
509 const abs = t.path.startsWith('/') ? t.path : `${S.cwd}/${t.path}`
510 let code = ''
511 try { code = (await $.fs.read(abs)).split('\n').slice(t.start - 1, t.end).map((l, i) => `${t.start + i} ${l}`).join('\n') } catch { /* file gone */ }
512 const g = m.groups.find(x => x.id === m.nav.group)
513 const p = threadPrompt(t, code, g?.title)
514 S.live = { id: t.id, mode: t.mode }
515 void $.prompt.submit({ text: `${p.visible}\n\n${p.context}`, asUser: true })
516}
517
518/** Definition spans for the viewer's `f` key: each definition runs to the line before the next at the same or lower indent. */
519function symbolsOf(src: readonly string[]): { name: string; start: number; end: number }[] {
520 const out: { name: string; start: number; end: number; indent: number }[] = []
521 const ind = (s: string) => (/^\s*/.exec(s)?.[0].length ?? 0)
522 src.forEach((l, i) => {
523 const name = definedAtLine(l)
524 if (name) out.push({ name, start: i + 1, end: src.length, indent: ind(l) })
525 })
526 for (const s of out) {
527 for (let j = s.start; j < src.length; j++) {
528 const l = src[j] ?? ''
529 if (l.trim() === '') continue
530 if (ind(l) <= s.indent && !/^\s*[)}\]]/.test(l)) { s.end = j; break }
531 if (ind(l) <= s.indent && /^\s*[)}\]]/.test(l)) { s.end = j + 1; break }
532 }
533 }
534 return out.map(({ name, start, end }) => ({ name, start, end }))
535}
536
537import { definedAt as definedAtLine } from './model.js'
538
539/* A resumed session can come back under a new id with the old history copied in. This finds the
540 session it continued from: the other transcript in the same project that holds the same first
541 message. Mods key their saved state by session id, so they look there when their own is empty. */
542async function predecessorOf($: import('claude-code').EngineInterface, cwd: string): Promise<string | undefined> {
543 try {
544 const home = (await $.env.get('HOME')) ?? ''
545 const dir = `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}`
546 const sid = await $.session.id()
547 const r = await $.process.run(['sh', '-c',
548 'f="$1/$2.jsonl"; [ -f "$f" ] || exit 0; u=$(grep -m1 -o \'"uuid":"[^"]*"\' "$f") || exit 0; ' +
549 'ls -t "$1"/*.jsonl 2>/dev/null | head -40 | grep -v "/$2.jsonl" | xargs grep -l -F "$u" 2>/dev/null | head -1',
550 'prev', dir, sid], { timeoutMs: 20000 })
551 const hit = r.stdout.trim().split('/').pop()?.replace(/\.jsonl$/, '')
552 return hit && hit !== sid ? hit : undefined
553 } catch {
554 return undefined
555 }
556}
557
558/**
559 * Saves the session's model within the store's budget. The store is one file for every session
560 * (4 MB cap), so: diffs are trimmed to their first lines, and the oldest other sessions are
561 * dropped until the whole store fits. A save that still fails never breaks the turn.
562 */
563async function persist($: import('claude-code').EngineInterface, m: Model): Promise<void> {
564 const LINES = 60
565 const slim: Model = {
566 ...m,
567 edits: m.edits.slice(-300).map(e => ({ ...e, hunks: e.hunks.slice(0, 12).map(h => ({ ...h, lines: h.lines.slice(0, LINES) })) })),
568 threads: m.threads.slice(-60),
569 }
570 const key = `model:${await $.session.id()}`
571 try {
572 const keys = (await $.store.keys()).filter(k => k.startsWith('model:') && k !== key)
573 // keep at most 6 other sessions, oldest first out
574 for (const k of keys.slice(0, Math.max(0, keys.length - 6))) await $.store.delete(k)
575 await $.store.set(key, slim)
576 } catch {
577 try {
578 for (const k of (await $.store.keys()).filter(k => k.startsWith('model:') && k !== key)) await $.store.delete(k)
579 await $.store.set(key, slim)
580 } catch { /* still too big: this session's pane lives in memory only */ }
581 }
582}
583hooks/model.ts 272 lines1import type { Edit, Group, GroupKind, Hunk, Model, Thread } from '../types'
2import { outsideCode, stripOutsideCode } from './metadata.js'
3
4export const EMPTY: Model = { edits: [], groups: [], threads: [], reviewed: [], nav: { level: 'groups', scope: 'session' }, focusSeq: 0 }
5export const KINDS: readonly GroupKind[] = ['feature', 'behaviour', 'fix', 'refactor', 'test', 'config', 'removal', 'docs']
6export const MAX_GROUPS = 24
7export const MAX_EDITS = 600
8
9// ---------------------------------------------------------------- symbols
10
11const DEF: readonly RegExp[] = [
12 /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
13 /^\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
14 /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=>/,
15 /^\s*(?:export\s+)?(?:interface|type)\s+([A-Za-z_$][\w$]*)/,
16 /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/,
17 /^\s*class\s+([A-Za-z_]\w*)\s*[:(]/,
18 /^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/,
19 /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)/,
20 /^\s*(?:(?:public|private|protected|static|final|synchronized|abstract)\s+)+[\w<>\[\],.?]+\s+([a-z_]\w*)\s*\([^;]*\)\s*(?:throws [\w, .]+)?\s*\{?\s*$/,
21 /^\s*(?:it|test|describe)\s*\(\s*(['"`])(.{1,60}?)\1/,
22 /^\s*on\(\s*(['"])([\w.]+)\1/,
23 /^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{\s*$/,
24]
25const NOT_NAMES = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'else', 'new', 'await', 'throw', 'case', 'typeof', 'delete', 'yield'])
26
27/** The name a line defines, if any: "fnName", "test: does x", "on turn.complete". */
28export function definedAt(line: string): string | undefined {
29 for (const re of DEF) {
30 const m = re.exec(line)
31 if (!m) continue
32 if (re.source.includes('it|test|describe')) return `${/^\s*describe/.test(line) ? 'describe' : 'test'}: ${m[2]}`
33 if (re.source.startsWith('^\\s*on\\(')) return `on ${m[2]}`
34 const name = m[1]
35 if (name && !NOT_NAMES.has(name)) return name
36 }
37 return undefined
38}
39
40/**
41 * The nearest definition at or above `line` (1-based) whose block plausibly holds it,
42 * by indentation: a definition indented less than the line, or at the same depth when
43 * the line is the definition itself.
44 */
45export function symbolAt(lines: readonly string[], line: number): string {
46 const at = Math.max(1, Math.min(line, lines.length))
47 const indent = (s: string) => (/^\s*/.exec(s)?.[0].length ?? 0)
48 const own = lines[at - 1] ?? ''
49 const here = definedAt(own)
50 if (here) return here
51 let depth = own.trim() === '' ? Infinity : indent(own)
52 for (let i = at - 2; i >= 0 && at - i < 400; i--) {
53 const l = lines[i] ?? ''
54 if (l.trim() === '') continue
55 const d = indent(l)
56 if (d < depth) {
57 const name = definedAt(l)
58 if (name) return name
59 depth = d
60 }
61 }
62 return ''
63}
64
65// ---------------------------------------------------------------- recording
66
67export function addedRemoved(hunks: readonly Hunk[]): { add: number; del: number } {
68 let add = 0, del = 0
69 for (const h of hunks) for (const l of h.lines) { if (l.startsWith('+')) add++; else if (l.startsWith('-')) del++ }
70 return { add, del }
71}
72
73/** First changed new-side line of a hunk (skips leading context). */
74export function firstChanged(h: Hunk): number {
75 let n = h.newStart
76 for (const l of h.lines) {
77 if (l.startsWith('+') || l.startsWith('-')) return n
78 n++
79 }
80 return h.newStart
81}
82
83export function record(m: Model, e: Edit): Model {
84 return { ...m, edits: [...m.edits, e].slice(-MAX_EDITS) }
85}
86
87// ---------------------------------------------------------------- grouping
88
89const GTAG = /<change-group\b([^>]*?)\/?>(?:<\/change-group>)?/g
90
91export type GroupTag = { id: string; kind: GroupKind; title: string; summary: string }
92
93export function slug(s: string): string {
94 return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'group'
95}
96
97export function readGroupTags(text: string): GroupTag[] {
98 const out: GroupTag[] = []
99 for (const m of outsideCode(text, GTAG)) {
100 const a: Record<string, string> = {}
101 for (const p of (m[1] ?? '').matchAll(/([a-z]+)="([^"]*)"/g)) a[p[1] ?? ''] = p[2] ?? ''
102 const title = (a.title ?? '').trim().slice(0, 70)
103 if (!title && !a.id) continue
104 const kind = (KINDS as readonly string[]).includes(a.kind ?? '') ? (a.kind as GroupKind) : 'feature'
105 out.push({ id: a.id ? slug(a.id) : slug(title), kind, title: title || a.id || 'Changes', summary: (a.summary ?? '').trim().slice(0, 300) })
106 }
107 return out
108}
109
110export function stripGroupTags(text: string): string {
111 return stripOutsideCode(text, GTAG)
112}
113
114/**
115 * Assigns the turn's unassigned edits. With tags: an edit goes to the tag whose title or
116 * summary names its file or symbol, else the first tag. Tags also create or refresh groups
117 * (same id = same group). Without tags: one fallback group per turn, named after the task.
118 */
119export function assign(m: Model, turn: number, tags: readonly GroupTag[]): Model {
120 const pending = m.edits.filter(e => e.turn === turn && !e.group)
121 let groups = [...m.groups]
122 const upsert = (g: Group) => {
123 const at = groups.findIndex(x => x.id === g.id)
124 if (at === -1) { groups.push(g); return }
125 // a reused id moves to the end, so the cap evicts the least recently active concept
126 const merged = { ...groups[at]!, kind: g.kind, title: g.title, summary: g.summary || groups[at]!.summary, turn: g.turn }
127 groups = [...groups.slice(0, at), ...groups.slice(at + 1), merged]
128 }
129 for (const t of tags) upsert({ ...t, turn })
130 if (pending.length === 0) return { ...m, groups: trimGroups(groups, m.edits) }
131
132 let pick: (e: Edit) => string
133 if (tags.length > 0) {
134 pick = e => {
135 const base = e.path.split('/').pop() ?? e.path
136 const hit = tags.find(t => {
137 const hay = `${t.title} ${t.summary}`.toLowerCase()
138 return hay.includes(base.toLowerCase()) || e.symbols.some(s => s && hay.includes(s.toLowerCase().replace(/^(test|describe|on): /, '')))
139 })
140 return (hit ?? tags[0]!).id
141 }
142 } else {
143 const task = pending.find(e => e.task)?.task
144 const id = task ? `task-${slug(task)}` : `turn-${turn}`
145 upsert({ id, kind: 'feature', title: task ?? 'Other changes', summary: '', turn })
146 pick = () => id
147 }
148 const edits = m.edits.map(e => (e.turn === turn && !e.group ? { ...e, group: pick(e) } : e))
149 return { ...m, edits, groups: trimGroups(groups, edits) }
150}
151
152/** Groups with no edits are dropped (a tag that named nothing); the cap keeps the newest. */
153function trimGroups(groups: Group[], edits: readonly Edit[]): Group[] {
154 const used = new Set(edits.map(e => e.group))
155 return groups.filter(g => used.has(g.id)).slice(-MAX_GROUPS)
156}
157
158// ---------------------------------------------------------------- views
159
160export type FileRow = { path: string; add: number; del: number; symbols: string[]; edits: Edit[]; created: boolean; deleted: boolean }
161export type GroupRow = Group & { files: FileRow[]; add: number; del: number; reviewedFiles: number }
162
163export function rows(m: Model): GroupRow[] {
164 const out: GroupRow[] = []
165 for (const g of [...m.groups].reverse()) {
166 const edits = m.edits.filter(e => e.group === g.id)
167 if (edits.length === 0) continue
168 const byPath = new Map<string, Edit[]>()
169 for (const e of edits) byPath.set(e.path, [...(byPath.get(e.path) ?? []), e])
170 const files: FileRow[] = [...byPath].map(([path, es]) => {
171 const { add, del } = addedRemoved(es.flatMap(e => e.hunks))
172 const symbols = [...new Set(es.flatMap(e => e.symbols).filter(Boolean))]
173 return { path, add, del, symbols, edits: es, created: es.some(e => e.created), deleted: es.at(-1)?.deleted === true }
174 })
175 const add = files.reduce((s, f) => s + f.add, 0)
176 const del = files.reduce((s, f) => s + f.del, 0)
177 const reviewedFiles = files.filter(f => m.reviewed.includes(`${g.id}|${f.path}`)).length
178 out.push({ ...g, files, add, del, reviewedFiles })
179 }
180 return out
181}
182
183/**
184 * The lines the viewer should mark, mapped onto the file as it is NOW: hunks from later
185 * edits shift earlier ones. Returns 1-based new-side line numbers that were added or
186 * changed, plus removal points (the line a removal sits above).
187 */
188export function marks(edits: readonly Edit[], allForFile: readonly Edit[]): { added: Set<number>; removedAt: Map<number, string[]> } {
189 const added = new Set<number>()
190 const removedAt = new Map<number, string[]>()
191 // edits are recorded in the order they were applied; a stable sort by turn keeps that order
192 const order = [...allForFile].sort((a, b) => a.turn - b.turn)
193 const mine = new Set(edits.map(e => e.id))
194 // map(line) after edit k: shift lines below each later hunk by its net growth
195 const shift = (line: number, after: number): number => {
196 let n = line
197 for (const e of order.slice(after + 1)) {
198 for (const h of e.hunks) {
199 const oldEnd = h.oldStart + h.oldLines
200 if (n >= oldEnd) n += h.newLines - h.oldLines
201 else if (n >= h.oldStart) return -1 // rewritten by a later edit
202 }
203 }
204 return n
205 }
206 order.forEach((e, k) => {
207 if (!mine.has(e.id)) return
208 for (const h of e.hunks) {
209 let n = h.newStart
210 let pendingDel: string[] = []
211 for (const l of h.lines) {
212 if (l.startsWith('-')) { pendingDel.push(l.slice(1)); continue }
213 if (pendingDel.length) {
214 const at = shift(n, k)
215 if (at > 0) removedAt.set(at, [...(removedAt.get(at) ?? []), ...pendingDel])
216 pendingDel = []
217 }
218 if (l.startsWith('+')) { const at = shift(n, k); if (at > 0) added.add(at) }
219 n++
220 }
221 if (pendingDel.length) { const at = shift(n, k); if (at > 0) removedAt.set(at, [...(removedAt.get(at) ?? []), ...pendingDel]) }
222 }
223 })
224 return { added, removedAt }
225}
226
227// ---------------------------------------------------------------- asks
228
229export function threadPrompt(t: Thread, code: string, groupTitle?: string): { visible: string; context: string } {
230 const where = `${t.path}:${t.start}${t.end !== t.start ? `-${t.end}` : ''}`
231 const visible = `${t.mode === 'ask' ? '❓' : '✎'} ${where} — ${t.text}`
232 const how = t.mode === 'ask'
233 ? 'This is a QUESTION about the code. Explain only: do not edit any file (edits are blocked this turn). Answer concisely and concretely, pointing at the lines.'
234 : 'This is a CHANGE REQUEST for exactly this code. Make the change (and any needed follow-ups such as tests), then say in one line what you changed.'
235 const context = [
236 `The user pointed at ${where} in the Changes pane${groupTitle ? ` (part of "${groupTitle}")` : ''} and wrote: ${t.text}`,
237 how,
238 `Selected code (${where}):`,
239 '```',
240 code.slice(0, 6000),
241 '```',
242 ].join('\n')
243 return { visible, context }
244}
245
246export function answerOf(reply: string): string {
247 return stripGroupTags(reply).replace(/<(insight|progress)\b[^>]*\/?>(?:[\s\S]*?<\/\1>)?/g, '').trim().slice(0, 3000)
248}
249
250/**
251 * Re-files a turn's edits by the labeller's per-file answer: existing concepts gain them, NEW
252 * ones are created (same new id = one concept). Fallback groups the turn made ("Other changes",
253 * a task name) and that end up empty are dropped.
254 */
255export function applyLabels(m: Model, turn: number, labels: readonly { path: string; id: string; isNew: boolean; title?: string; kind?: GroupKind; summary?: string }[]): Model {
256 if (labels.length === 0) return m
257 let groups = [...m.groups]
258 for (const l of labels) {
259 if (!l.isNew || groups.some(g => g.id === l.id)) continue
260 groups.push({ id: l.id, kind: l.kind ?? 'feature', title: l.title ?? l.id, summary: l.summary ?? '', turn })
261 }
262 const byPath = new Map(labels.map(l => [l.path, l.id]))
263 const edits = m.edits.map(e => (e.turn === turn && byPath.has(e.path) ? { ...e, group: byPath.get(e.path)! } : e))
264 // touched concepts move to the end, so the cap keeps what is active
265 const touched = new Set(byPath.values())
266 groups = [...groups.filter(g => !touched.has(g.id)), ...groups.filter(g => touched.has(g.id)).map(g => ({ ...g, turn }))]
267 return { ...m, edits, groups: trimGroups(groups, edits) }
268}
269
270/** Whether a group was made by the fallback rather than named by Claude or the labeller. */
271export const isFallback = (g: Group) => g.id.startsWith('turn-') || g.id.startsWith('task-')
272hooks/viewer.tsx 199 lines1/* A Client surface module: the pointable code viewer. Runs on the drawing thread with no $;
2 keys and the mouse move a cursor and selection, and actions are posted to the hooks module. */
3import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface, RenderElement } from 'claude-code'
4
5export type ViewerLine = { n: number; text: string; mark?: 'add' | 'mod'; removed?: string[] }
6export type ViewerThread = { start: number; end: number; mode: 'ask' | 'change'; text: string; answer?: string; pending: boolean }
7export type ViewerProps = {
8 path: string
9 lines: ViewerLine[]
10 /** 1-based line to put the cursor on whenever `jump` changes. */
11 cursor: number
12 jump: number
13 symbols: { name: string; start: number; end: number }[]
14 threads: ViewerThread[]
15 height: number
16 reviewed: boolean
17}
18export type View = { cur: number; anchor: number | null; top: number; jump: number; showRemoved: boolean; open: number | null }
19/** The listeners outlive one draw: they read the newest props and size through this. */
20type State = View & { live: { p: ViewerProps; h: number } }
21
22const C = {
23 add: '#34d399', addBg: '#0f2e24', del: '#f87171', delBg: '#2e1215', sel: '#2b3350', cur: '#3b4470',
24 gutter: '#5b6170', ask: '#60a5fa', change: '#fbbf24', dim: '#7c8191', thread: '#a78bfa',
25}
26
27const indexOf = (p: ViewerProps, n: number): number => {
28 const f = p.lines.findIndex(l => l.n >= n)
29 return f === -1 ? Math.max(0, p.lines.length - 1) : f
30}
31const isMarked = (l: ViewerLine | undefined) => !!(l && (l.mark || l.removed))
32const symbolAt = (p: ViewerProps, n: number) =>
33 [...p.symbols].filter(y => y.start <= n && n <= y.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0]
34
35/** One key: the next view, or a message for the hooks module. Pure, so it is easy to reason about. */
36export function act(v: View, p: ViewerProps, h: number, k: ClientKeyEvent): { view?: View; post?: Record<string, unknown> } {
37 const last = Math.max(0, p.lines.length - 1)
38 const lo = Math.min(v.cur, v.anchor ?? v.cur), hi = Math.max(v.cur, v.anchor ?? v.cur)
39 const move = (to: number, keep: boolean): View => {
40 const cur = Math.max(0, Math.min(last, to))
41 const top = cur < v.top ? cur : cur >= v.top + h ? cur - h + 1 : v.top
42 return { ...v, cur, top, anchor: keep ? (v.anchor ?? v.cur) : null }
43 }
44 const sel = { start: p.lines[lo]?.n ?? 1, end: p.lines[hi]?.n ?? 1 }
45 const extend = !!k.shift || v.anchor !== null
46 switch (k.key) {
47 case 'up': case 'k': return { view: move(v.cur - 1, extend) }
48 case 'down': case 'j': return { view: move(v.cur + 1, extend) }
49 case 'pageup': return { view: move(v.cur - h + 2, extend) }
50 case 'pagedown': case ' ': return { view: move(v.cur + h - 2, extend) }
51 case 'home': case 'g': return { view: move(0, false) }
52 case 'end': case 'G': return { view: move(last, false) }
53 case 'v': return { view: { ...v, anchor: v.anchor === null ? v.cur : null } }
54 case 'n': {
55 for (let i = v.cur + 1; i <= last; i++) if (isMarked(p.lines[i]) && !isMarked(p.lines[i - 1])) return { view: move(i, false) }
56 return {}
57 }
58 case 'p': case 'N': {
59 for (let i = v.cur - 1; i >= 0; i--) if (isMarked(p.lines[i]) && !isMarked(p.lines[i - 1])) return { view: move(i, false) }
60 return {}
61 }
62 case 'f': {
63 const sym = symbolAt(p, p.lines[v.cur]?.n ?? 1)
64 if (!sym) return {}
65 const a = indexOf(p, sym.start), b = indexOf(p, sym.end)
66 return { view: { ...v, anchor: a, cur: b, top: Math.min(a, Math.max(v.top, b - h + 1)) } }
67 }
68 case 'x': return { view: { ...v, showRemoved: !v.showRemoved } }
69 case 't': case 'return': {
70 const n = p.lines[v.cur]?.n ?? 0
71 const i = p.threads.findIndex(t => t.start <= n && n <= t.end)
72 if (i === -1) return {}
73 return { view: { ...v, open: v.open === i ? null : i } }
74 }
75 case 'a': return { post: { type: 'ask', mode: 'ask', ...sel }, view: { ...v, anchor: null } }
76 case 'c': return { post: { type: 'ask', mode: 'change', ...sel }, view: { ...v, anchor: null } }
77 case 'r': return { post: { type: 'reviewed' } }
78 case 'left': case 'h': case 'backspace': return { post: { type: 'back' } }
79 case ']': return { post: { type: 'file', step: 1 } }
80 case '[': return { post: { type: 'file', step: -1 } }
81 }
82 return {}
83}
84
85export function pointer(v: View, p: ViewerProps, e: ClientPointerEvent): View | undefined {
86 const i = Math.max(0, Math.min(p.lines.length - 1, v.top + e.y))
87 if (e.type === 'down') return { ...v, cur: i, anchor: i }
88 if (e.type === 'move' && v.anchor !== null) return { ...v, cur: i }
89 if (e.type === 'up' && v.anchor === i && v.cur === i) return { ...v, anchor: null }
90 return undefined
91}
92
93export const Viewer: ClientModule<ViewerProps, State> = (props, s: ClientSurface<State>) => {
94 const { Box, Text } = s.elements
95 const height = Math.max(4, Math.min(props.height, s.rows || props.height))
96 let st = s.state
97 const live = st?.live ?? { p: props, h: height }
98 live.p = props
99 live.h = height
100
101 if (!st) {
102 const cur = indexOf(props, props.cursor)
103 st = { cur, anchor: null, top: Math.max(0, cur - Math.floor(height / 3)), jump: props.jump, showRemoved: true, open: null, live }
104 s.setState(st)
105 s.onKey(k => {
106 const now = s.state
107 if (!now) return
108 const r = act(now, now.live.p, now.live.h, k)
109 if (r.view) s.setState({ ...r.view, live: now.live })
110 if (r.post) s.post(r.post as never)
111 })
112 s.onPointer(e => {
113 const now = s.state
114 if (!now) return
115 const v = pointer(now, now.live.p, e)
116 if (v) s.setState({ ...v, live: now.live })
117 })
118 } else if (st.jump !== props.jump) {
119 // a file switch or a landed change: put the cursor where the hooks module says
120 const cur = indexOf(props, props.cursor)
121 st = { ...st, cur, anchor: null, top: Math.max(0, cur - Math.floor(height / 3)), jump: props.jump, open: null }
122 s.setState(st)
123 }
124
125 const v = st
126 const lo = Math.min(v.cur, v.anchor ?? v.cur), hi = Math.max(v.cur, v.anchor ?? v.cur)
127 const width = Math.max(20, s.columns || 80)
128 const gw = String(props.lines.at(-1)?.n ?? 1).length
129 const codeW = Math.max(8, width - gw - 5)
130 const out: RenderElement[] = []
131 let used = 0
132 for (let i = v.top; i < props.lines.length && used < height; i++) {
133 const l = props.lines[i]!
134 if (v.showRemoved && l.removed?.length) {
135 for (const r of l.removed.slice(0, 4)) {
136 if (used >= height) break
137 out.push(
138 <Box key={`d${i}-${used}`} flexDirection="row">
139 <Text color={C.del}>{`${' '.repeat(gw + 1)}−│ `}</Text>
140 <Text color={C.del} backgroundColor={C.delBg} wrap="truncate-end">{r.replace(/\t/g, ' ').slice(0, codeW)}</Text>
141 </Box>,
142 )
143 used++
144 }
145 if (l.removed.length > 4 && used < height) {
146 out.push(<Text key={`dm${i}`} color={C.del} dimColor>{`${' '.repeat(gw + 4)}… ${l.removed.length - 4} more removed`}</Text>)
147 used++
148 }
149 }
150 if (used >= height) break
151 const inSel = i >= lo && i <= hi && v.anchor !== null
152 const isCur = i === v.cur
153 const bg = isCur ? C.cur : inSel ? C.sel : l.mark ? C.addBg : undefined
154 const ti = props.threads.findIndex(t => t.end === l.n)
155 out.push(
156 <Box key={`l${i}`} flexDirection="row">
157 <Text color={inSel || isCur ? C.change : C.gutter}>{`${inSel || isCur ? '▌' : ' '}${String(l.n).padStart(gw)}`}</Text>
158 <Text color={l.mark ? C.add : C.gutter} bold={!!l.mark}>{`${l.mark ? '+' : ' '}│ `}</Text>
159 <Text backgroundColor={bg} wrap="truncate-end">{(l.text.replace(/\t/g, ' ') || ' ').slice(0, codeW)}</Text>
160 {ti !== -1 && <Text color={props.threads[ti]!.mode === 'ask' ? C.ask : C.change}>{props.threads[ti]!.pending ? ' ⧗' : ' ●'}</Text>}
161 </Box>,
162 )
163 used++
164 if (ti !== -1 && (v.open === ti || props.threads[ti]!.pending) && used < height) {
165 const t = props.threads[ti]!
166 const color = t.mode === 'ask' ? C.ask : C.change
167 const wrapW = Math.max(10, width - gw - 8)
168 const body = (t.pending ? [`${t.mode === 'ask' ? 'Asking' : 'Changing'}…`] : (t.answer ?? '').split('\n'))
169 .flatMap(x => (x === '' ? [''] : x.match(new RegExp(`.{1,${wrapW}}`, 'g')) ?? ['']))
170 const room = Math.max(1, Math.min(10, height - used - 3))
171 out.push(
172 <Box key={`t${i}`} flexDirection="column" borderStyle="round" borderColor={color} marginLeft={gw + 2}>
173 <Text color={color} bold wrap="truncate-end">{`${t.mode === 'ask' ? '❓' : '✎'} ${t.text}`}</Text>
174 {body.slice(0, room).map((x, j) => <Text key={`tb${j}`} wrap="truncate-end">{x}</Text>)}
175 {body.length > room && <Text dimColor>{`… ${body.length - room} more lines in the transcript`}</Text>}
176 </Box>,
177 )
178 used += Math.min(body.length, room) + 3
179 }
180 }
181 const selN = hi - lo + 1
182 const curLine = props.lines[v.cur]?.n ?? 0
183 const sym = symbolAt(props, curLine)
184 const onThread = props.threads.some(t => t.start <= curLine && curLine <= t.end)
185 return (
186 <Box flexDirection="column">
187 {out}
188 <Box flexDirection="row" columnGap={2}>
189 <Text color={v.anchor !== null ? C.change : C.dim}>{v.anchor !== null ? `▌ ${selN} line${selN > 1 ? 's' : ''} selected · a ask · c change` : `line ${curLine}/${props.lines.at(-1)?.n ?? 0}`}</Text>
190 {sym && <Text color={C.thread}>{`ƒ ${sym.name}`}</Text>}
191 {onThread && v.anchor === null && <Text color={C.ask}>t note</Text>}
192 {props.reviewed && <Text color={C.add}>✔ reviewed</Text>}
193 </Box>
194 </Box>
195 )
196}
197
198export default Viewer
199hooks/branch.ts 102 lines1/* Branch scope: the changes since the base branch, read from git and shaped like the session's
2 model (edits + groups), so the same pane and viewer show them. No model call: each commit's
3 subject is its concept; a file goes under the newest commit that touched it. */
4import type { Edit, Group, Hunk, Model } from '../types'
5import { EMPTY, slug, symbolAt } from './model.js'
6
7export const GIT_ENV = { LC_ALL: 'C', LANGUAGE: '' } as const
8export const NOLOCK = '--no-optional-locks'
9
10/** Parses `git diff -U3` output into per-file hunks (renames read as their new path). */
11export function parseDiff(out: string): Map<string, Hunk[]> {
12 const files = new Map<string, Hunk[]>()
13 let path: string | null = null
14 let h: Hunk | null = null
15 let oldPath: string | null = null
16 for (const line of out.split('\n')) {
17 if (line.startsWith('diff --git ')) { h = null; path = null; oldPath = null; continue }
18 if (line.startsWith('+++ ')) {
19 const p = line.slice(4).trim()
20 path = p === '/dev/null' ? oldPath : p.replace(/^b\//, '')
21 if (path && !files.has(path)) files.set(path, [])
22 continue
23 }
24 if (line.startsWith('--- ')) { oldPath = line.slice(4).trim().replace(/^a\//, ''); continue }
25 const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
26 if (m && path) {
27 h = { oldStart: Number(m[1]), oldLines: m[2] === undefined ? 1 : Number(m[2]), newStart: Number(m[3]), newLines: m[4] === undefined ? 1 : Number(m[4]), lines: [] }
28 files.get(path)!.push(h)
29 continue
30 }
31 if (h && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) h.lines.push(line)
32 }
33 for (const [p, hs] of files) if (hs.length === 0) files.delete(p)
34 return files
35}
36
37export type Commit = { sha: string; subject: string; files: string[] }
38
39/** Parses `git log --name-only --format=%x1e%h%x1f%s` (newest first). */
40export function parseLog(out: string): Commit[] {
41 return out.split('\x1e').map(c => c.trim()).filter(Boolean).map(c => {
42 const [head, ...rest] = c.split('\n')
43 const [sha = '', subject = ''] = (head ?? '').split('\x1f')
44 return { sha, subject, files: rest.map(f => f.trim()).filter(Boolean) }
45 })
46}
47
48const KIND_OF = (s: string): Group['kind'] =>
49 /^(fix|bug|hotfix)\b/i.test(s) ? 'fix'
50 : /^(test|tests)\b/i.test(s) ? 'test'
51 : /^(refactor|cleanup|chore)\b/i.test(s) ? 'refactor'
52 : /^(docs?)\b/i.test(s) ? 'docs'
53 : /^(ci|build|config|deps)\b/i.test(s) ? 'config'
54 : /^(remove|delete|drop)\b/i.test(s) ? 'removal'
55 : 'feature'
56
57/**
58 * The branch model: one group per commit (subject as title, conventional prefix as kind),
59 * uncommitted work as its own group, and the session's group used instead for files this
60 * session edited, so its concepts carry over.
61 */
62export function branchModel(
63 root: string, diff: Map<string, Hunk[]>, log: readonly Commit[], uncommitted: ReadonlySet<string>,
64 session: Model, read: (abs: string) => string[],
65): Model {
66 const owner = new Map<string, Commit | 'wip'>()
67 for (const p of diff.keys()) if (uncommitted.has(p)) owner.set(p, 'wip')
68 for (const c of log) for (const f of c.files) if (diff.has(f) && !owner.has(f)) owner.set(f, c)
69 const sessionGroup = new Map<string, string>()
70 for (const e of session.edits) if (e.group) sessionGroup.set(e.path, e.group)
71
72 const groups: Group[] = []
73 const add = (g: Group) => { if (!groups.some(x => x.id === g.id)) groups.push(g) }
74 const edits: Edit[] = []
75 let i = 0
76 for (const [p, hunks] of diff) {
77 const abs = `${root}/${p}`
78 const sg = sessionGroup.get(abs)
79 const own = owner.get(p)
80 let gid: string
81 if (sg) {
82 const g = session.groups.find(x => x.id === sg)
83 if (g) add({ ...g, turn: 0 })
84 gid = sg
85 } else if (own && own !== 'wip') {
86 gid = `c-${own.sha}`
87 add({ id: gid, kind: KIND_OF(own.subject), title: own.subject.replace(/^\w+(\([^)]*\))?!?:\s*/, '').slice(0, 70) || own.subject, summary: `commit ${own.sha}`, turn: 0 })
88 } else {
89 gid = 'wip'
90 add({ id: gid, kind: 'feature', title: 'Uncommitted work', summary: 'Changes in the working tree that are not in a commit yet.', turn: 0 })
91 }
92 const lines = read(abs)
93 edits.push({ id: `b${i++}`, turn: 0, path: abs, hunks, ...(hunks.some(h => h.newStart === 0 && h.newLines === 0) ? { deleted: true } : {}), symbols: hunks.map(h => symbolAt(lines, h.newStart)), group: gid })
94 }
95 // newest commit first, matching the session view (rows() reverses)
96 const order = (g: Group) => g.id === 'wip' ? -1 : g.id.startsWith('c-') ? log.findIndex(c => `c-${c.sha}` === g.id) : -2
97 groups.sort((a, b) => order(b) - order(a))
98 return { ...EMPTY, edits, groups, threads: session.threads, reviewed: session.reviewed, nav: { ...session.nav, scope: 'branch' } }
99}
100
101export { slug }
102hooks/shell.ts 114 lines1/* Shell edits: Bash calls that change files (sed -i, heredocs, scripts) give no structured patch,
2 so the files a command names are read before and after it runs and the difference is diffed here. */
3import type { Hunk } from '../types'
4
5export const MAX_FILES = 40
6export const MAX_BYTES = 512 * 1024
7
8const SKIP_DIR = /(^|\/)(\.git|node_modules|\.venv|venv|__pycache__|dist|build|\.next|target)(\/|$)/
9const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|tar|jar|class|so|dylib|o|a|bin|pcap|pcapng|mp4|mov|woff2?|ttf|lock)$/i
10
11/**
12 * The files a shell command may touch, as absolute paths: path-like words in it, with simple
13 * `NAME=value` assignments and `$NAME`/`${NAME}`/`~` expanded, resolved against the session's
14 * directory and any directory the command `cd`s into.
15 */
16export function candidates(command: string, cwd: string, home: string): string[] {
17 const vars = new Map<string, string>([['HOME', home]])
18 for (const m of command.matchAll(/(?:^|[\s;&|(])([A-Za-z_][A-Za-z0-9_]*)=("([^"]*)"|'([^']*)'|([^\s;&|)]+))/g)) {
19 vars.set(m[1]!, m[3] ?? m[4] ?? m[5] ?? '')
20 }
21 const expand = (s: string) => {
22 let out = s
23 for (let i = 0; i < 3; i++) {
24 out = out.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (all, a, b) => vars.get(a ?? b) ?? all)
25 }
26 return out.replace(/^~(?=\/|$)/, home)
27 }
28 const bases = [cwd]
29 for (const m of command.matchAll(/(?:^|[;&|(]\s*)cd\s+("([^"]+)"|'([^']+)'|([^\s;&|)]+))/g)) {
30 const d = expand(m[2] ?? m[3] ?? m[4] ?? '')
31 if (d && !d.includes('$')) bases.push(d.startsWith('/') ? d : `${cwd}/${d}`)
32 }
33 const out = new Set<string>()
34 // words that look like file paths: contain a slash or end in an extension
35 for (const m of command.matchAll(/(?:\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|~|\.{1,2}|\/)?[A-Za-z0-9_@%+=:,.\/{}$~-]*[A-Za-z0-9_-]\.[A-Za-z0-9]{1,8}\b|(?:\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|~)\/[A-Za-z0-9_@%+=:,.\/{}$-]+/g)) {
36 const raw = expand(m[0].replace(/[,:]+$/, ''))
37 if (raw.includes('$') || raw.includes('*') || /^[a-z]+:\/\//i.test(raw) || raw.length > 400) continue
38 if (BINARY_EXT.test(raw)) continue
39 const tries = raw.startsWith('/') ? [raw] : bases.map(b => `${b}/${raw}`)
40 for (const t of tries) {
41 const p = normalize(t)
42 if (SKIP_DIR.test(p) || p === '/' ) continue
43 out.add(p)
44 }
45 if (out.size >= MAX_FILES * 2) break
46 }
47 return [...out]
48}
49
50export function normalize(p: string): string {
51 const parts: string[] = []
52 for (const seg of p.split('/')) {
53 if (seg === '' || seg === '.') continue
54 if (seg === '..') parts.pop()
55 else parts.push(seg)
56 }
57 return '/' + parts.join('/')
58}
59
60/**
61 * Unified-diff hunks (3 lines of context) between two texts. Common prefix and suffix are trimmed,
62 * then the middle is diffed line by line (LCS); a middle too large to diff cheaply becomes one
63 * replace hunk, which is still correct, just coarser.
64 */
65export function diffHunks(before: string, after: string, context = 3): Hunk[] {
66 if (before === after) return []
67 const a = before === '' ? [] : before.split('\n'), b = after === '' ? [] : after.split('\n')
68 let pre = 0
69 while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
70 let suf = 0
71 while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++
72 const am = a.slice(pre, a.length - suf), bm = b.slice(pre, b.length - suf)
73
74 // ops over the middle: ' ' keep, '-' delete, '+' insert
75 type Op = { t: ' ' | '-' | '+'; s: string }
76 let ops: Op[]
77 if (am.length * bm.length > 4_000_000) {
78 ops = [...am.map(s => ({ t: '-' as const, s })), ...bm.map(s => ({ t: '+' as const, s }))]
79 } else {
80 const n = am.length, m = bm.length
81 const L: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
82 for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
83 L[i]![j] = am[i] === bm[j] ? L[i + 1]![j + 1]! + 1 : Math.max(L[i + 1]![j]!, L[i]![j + 1]!)
84 ops = []
85 let i = 0, j = 0
86 while (i < n && j < m) {
87 if (am[i] === bm[j]) { ops.push({ t: ' ', s: am[i]! }); i++; j++ }
88 else if (L[i + 1]![j]! >= L[i]![j + 1]!) ops.push({ t: '-', s: am[i++]! })
89 else ops.push({ t: '+', s: bm[j++]! })
90 }
91 while (i < n) ops.push({ t: '-', s: am[i++]! })
92 while (j < m) ops.push({ t: '+', s: bm[j++]! })
93 }
94 // the whole file as ops, then cut into hunks around the changes
95 const all: Op[] = [...a.slice(0, pre).map(s => ({ t: ' ' as const, s })), ...ops, ...a.slice(a.length - suf).map(s => ({ t: ' ' as const, s }))]
96 const changed = all.map((o, k) => (o.t !== ' ' ? k : -1)).filter(k => k >= 0)
97 const hunks: Hunk[] = []
98 let k = 0
99 while (k < changed.length) {
100 let start = Math.max(0, changed[k]! - context)
101 let end = Math.min(all.length - 1, changed[k]! + context)
102 while (k + 1 < changed.length && changed[k + 1]! - context <= end + 1) { k++; end = Math.min(all.length - 1, changed[k]! + context) }
103 k++
104 // line numbers at `start`
105 let oldStart = 1, newStart = 1
106 for (let x = 0; x < start; x++) { if (all[x]!.t !== '+') oldStart++; if (all[x]!.t !== '-') newStart++ }
107 const slice = all.slice(start, end + 1)
108 const oldLines = slice.filter(o => o.t !== '+').length
109 const newLines = slice.filter(o => o.t !== '-').length
110 hunks.push({ oldStart: oldLines === 0 ? oldStart - 1 : oldStart, oldLines, newStart: newLines === 0 ? newStart - 1 : newStart, newLines, lines: slice.map(o => o.t + o.s) })
111 }
112 return hunks.slice(0, 40)
113}
114hooks/label.ts 74 lines1/* The cheap-model fallback for concept names: when a turn edits files and Claude adds no
2 <change-group> tag, one small call files the edits under the session's existing concepts,
3 or names a new one. Existing concepts come first, so a session builds a few growing concepts
4 rather than one per turn. */
5import type { Edit, Group, GroupKind } from '../types'
6import { KINDS, addedRemoved, slug } from './model.js'
7
8export type Label = { path: string; id: string; isNew: boolean; title?: string; kind?: GroupKind; summary?: string }
9
10const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n) + '…' : s)
11
12/** A compact, bounded description of one turn's edits: path, symbols and a few changed lines. */
13export function describeEdits(edits: readonly Edit[], rel: (p: string) => string): string {
14 const byPath = new Map<string, Edit[]>()
15 for (const e of edits) byPath.set(e.path, [...(byPath.get(e.path) ?? []), e])
16 const out: string[] = []
17 for (const [p, es] of [...byPath].slice(0, 15)) {
18 const hunks = es.flatMap(e => e.hunks)
19 const { add, del } = addedRemoved(hunks)
20 const syms = [...new Set(es.flatMap(e => e.symbols).filter(Boolean))].slice(0, 4)
21 const sample = hunks.flatMap(h => h.lines.filter(l => l.startsWith('+') || l.startsWith('-'))).slice(0, 8).map(l => ' ' + clip(l, 110))
22 out.push(`- ${rel(p)} (+${add} -${del})${syms.length ? ` in ${syms.join(', ')}` : ''}${es.some(e => e.created) ? ' [new file]' : ''}`, ...sample)
23 }
24 return out.join('\n').slice(0, 6000)
25}
26
27export function labelPrompt(ask: string, edits: string, groups: readonly Group[]): string {
28 const existing = groups.length === 0
29 ? '(none yet)'
30 : groups.slice(-15).map(g => `- id=${g.id} [${g.kind}] ${g.title}${g.summary ? ` — ${clip(g.summary, 140)}` : ''}`).join('\n')
31 return `You label a coding session's edits into a few CONCEPTS (features, fixes, behaviour changes) for a review pane.
32
33Existing concepts in this session:
34${existing}
35
36What the user asked for this turn:
37${clip(ask || '(not available)', 1200)}
38
39This turn's edits:
40${edits}
41
42For EACH edited file, answer one line:
43 <path> => <existing-id>
44or, only if the work is genuinely new and fits no existing concept:
45 <path> => NEW <short-id> | <kind> | <title, plain words, <= 8 words> | <one sentence: what it does now and why>
46kind is one of: ${KINDS.join(', ')}.
47Prefer an existing concept whenever the edit continues or supports it (tests, docs and config for a feature belong with that feature). Files that are part of the same new work share one NEW id; repeat the same title for them. Output only those lines.`
48}
49
50/** Reads the call's answer; unknown ids and malformed lines are dropped, never guessed. */
51export function parseLabels(reply: string, paths: readonly string[], known: ReadonlySet<string>, rel: (p: string) => string): Label[] {
52 const byRel = new Map(paths.map(p => [rel(p), p]))
53 const out: Label[] = []
54 for (const raw of reply.split('\n')) {
55 const line = raw.trim().replace(/^[-*]\s+/, '').replace(/`/g, '')
56 const m = /^(.+?)\s*=>\s*(.+)$/.exec(line)
57 if (!m) continue
58 const p = byRel.get(m[1]!.trim()) ?? paths.find(x => x.endsWith('/' + m[1]!.trim()) || x === m[1]!.trim())
59 if (!p || out.some(o => o.path === p)) continue
60 const rhs = m[2]!.trim()
61 const n = /^NEW\s+([^|]+)\|\s*([^|]+)\|\s*([^|]+)\|?\s*(.*)$/i.exec(rhs)
62 if (n) {
63 const kind = n[2]!.trim().toLowerCase()
64 const title = n[3]!.trim().slice(0, 70)
65 if (!title) continue
66 out.push({ path: p, id: slug(n[1]!.trim()), isNew: true, title, kind: (KINDS as readonly string[]).includes(kind) ? (kind as GroupKind) : 'feature', summary: n[4]!.trim().slice(0, 300) })
67 continue
68 }
69 const id = rhs.split(/\s/)[0]!.replace(/^id=/, '')
70 if (known.has(id)) out.push({ path: p, id, isNew: false })
71 }
72 return out
73}
74hooks/metadata.ts 24 lines1/** Reporting tags inside Markdown code are examples. Preserve them verbatim. */
2export function codeSpans(text: string): readonly (readonly [number, number])[] {
3 const spans: [number, number][] = []
4 const re = /(`+|~{3,})/g
5 let m: RegExpExecArray | null
6 while ((m = re.exec(text))) {
7 const marker = m[0]
8 if (marker[0] === '~' && text.slice(text.lastIndexOf('\n', m.index) + 1, m.index).trim()) continue
9 const end = text.indexOf(marker, re.lastIndex)
10 // An unfinished code span/fence is still code while a reply streams.
11 const stop = end < 0 ? text.length : end + marker.length
12 spans.push([m.index, stop]); re.lastIndex = stop
13 }
14 return spans
15}
16export function outsideCode(text: string, tag: RegExp): RegExpMatchArray[] {
17 const spans = codeSpans(text)
18 return [...text.matchAll(tag)].filter(m => !spans.some(([a, b]) => m.index! >= a && m.index! < b))
19}
20export function stripOutsideCode(text: string, tag: RegExp): string {
21 for (const m of outsideCode(text, tag).reverse()) text = text.slice(0, m.index!) + text.slice(m.index! + m[0].length)
22 return text.replace(/\n{3,}/g, '\n\n')
23}
24types/index.d.ts 60 lines1export type GroupKind = 'feature' | 'behaviour' | 'fix' | 'refactor' | 'test' | 'config' | 'removal' | 'docs'
2
3/** One hunk as the edit tools report it (structuredPatch). */
4export type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
5
6/** One edit Claude made: a file, the hunks, and what it was doing then. */
7export type Edit = {
8 id: string
9 turn: number
10 path: string
11 hunks: Hunk[]
12 /** The enclosing function/test/class per hunk, by a heuristic scan; '' when none. */
13 symbols: string[]
14 created?: boolean
15 deleted?: boolean
16 /** The concept group; set when the turn's tag (or the fallback) assigns it. */
17 group?: string
18 /** What Claude was on when it made the edit: the active task, if any. */
19 task?: string
20}
21
22export type Group = { id: string; kind: GroupKind; title: string; summary: string; turn: number }
23
24/** An ask or change request pointed at lines, and Claude's answer to it. */
25export type Thread = {
26 id: string
27 path: string
28 start: number
29 end: number
30 mode: 'ask' | 'change'
31 text: string
32 turn: number
33 status: 'pending' | 'answered'
34 answer?: string
35}
36
37/** Where the person is in the pane. */
38export type Nav = {
39 level: 'groups' | 'files' | 'code'
40 group?: string
41 file?: string
42 scope: 'session' | 'branch'
43}
44
45export type Model = {
46 edits: Edit[]
47 groups: Group[]
48 threads: Thread[]
49 reviewed: string[]
50 nav: Nav
51 /** Bumped when a pointed change lands, so the viewer jumps to it. */
52 focusSeq: number
53}
54
55declare module 'claude-code' {
56 interface PluginState {
57 changes: { model: Model }
58 }
59}
60