SLOPSHOPPER

changes

/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

newpanerowsguardcommandtoast
v0.1.1MITupdated 2026-10-09sruthik27/claude-foundry/mods/changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changes
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ What changed · this session · 0 conceptss: │ ┃ ⏺ Read(src/auth.ts) │ ┃ No edits yet this session. As Claude changes ⎿ Read 6 lines │ ┃ files, they appear here grouped by what they ⏺ Update(src/auth.ts) │ ┃ are for. Press s for the whole branch. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
What changed · this session · 0 concepts s: 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.
README

changes

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.

Install

claude plugin install changes --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

/changes
/changes branch

Details

MIT licensed. Maintained by Sruthik Issac.

Source 8 files
hooks/register.tsx 583 lines
1import { 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}
583
hooks/model.ts 272 lines
1import 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-')
272
hooks/viewer.tsx 199 lines
1/* 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
199
hooks/branch.ts 102 lines
1/* 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 }
102
hooks/shell.ts 114 lines
1/* 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}
114
hooks/label.ts 74 lines
1/* 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}
74
hooks/metadata.ts 24 lines
1/** 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}
24
types/index.d.ts 60 lines
1export 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