Tells you when Claude's checks no longer hold (failed, stale after its last edit, or never run) and reruns them in one press. A line above the prompt while you…

Tells you when Claude's checks no longer hold: tests that failed, tests that passed before Claude edited again, or code changed with no check run at all. One press reruns the stale ones. It says so in a line above the prompt, without opening anything.
/session-pane opens the full pane:
/plugin install session-pane --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope.
/session-pane shows or hides the pane. It never opens by itself. (/session is Claude Code's own command.)
While the pane is hidden, three small signals cover what needs you:
· summarising in ~N turns is appended once that is two turns away or less.The band and the toasts stay quiet while the pane is open.
haiku, at most 150 tokens out). While the pane is hidden, nothing is sent: your messages wait, and one call reads all of them when you open it. While it is open, one call per message of three words or more; replies like "yes" or "go on" never cost one. The draft title before that is free.git rev-parse and git ls-files -m -o --exclude-standard in the session's repo and lists the dirty files whose mtime falls inside the call. That's a guess: another process writing during the call is credited to the session. It looks at the first 200 dirty files in path order, so in a repo with more, a command's edits further down are missed. Without git, outside a repo, or on a surface other than the CLI, only tool edits are listed.npm test && rm -rf dist), not one that sets a variable, and not one stored with a secret masked or cut short.$.session.usage({ breakdown: 'summary' }), no request sent) for the window's size and the auto-compact threshold.claude --plugin-dir plugins/session-pane # loads it from source
tsc -p plugins # type-checks every plugin against plugins/types/
claude plugin test plugins/session-pane # tests
claude plugin validate plugins/session-panehooks/session-pane.tsx 338 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import type { Recap, Rerun, View } from '../types'
4import { alertsOf, applyGoal, bandOf, compactIn, empty, GOAL_SYSTEM, GOAL_TOKENS, goalPrompt, goalWorthy, isNoise, isQuery, mergeShellEdits, recapNotes, recordTurn, reduce, replay, snapshotOf, splitChecks, turnsLeft, whoOf, type SessionModel, type Seen } from './model'
5import { draw, type Act, type Els } from './views'
6
7// A pane that answers four questions about the session: what are we trying to do, what is Claude doing, what can it
8// see, and what has changed. One `*` hook folds every event into a SessionModel (model.ts); a 2 s clock writes it to
9// $.state, which redraws the pane, and to $.store, so a reload or a resume shows the same pane.
10// While the pane is hidden, the band above the prompt, toasts and the spinner carry what needs the person (AMBIENT).
11// Sections, in order (search `// ===`): STATE, CAPTURE, FLUSH, GOAL, RECAP, RERUN, AMBIENT, HOOKS
12
13// === STATE ===
14const PANE = 'session-pane'
15const TITLE = 'Session'
16const modelAtom = atom({ plugin: 'session-pane', key: 'model' } as const, empty())
17const viewAtom = atom({ plugin: 'session-pane', key: 'view' } as const, 'overview' as View)
18const helpAtom = atom({ plugin: 'session-pane', key: 'help' } as const, false)
19const diffAtom = atom({ plugin: 'session-pane', key: 'diff' } as const, '')
20const recapAtom = atom({ plugin: 'session-pane', key: 'recap' } as const, { status: 'idle', text: '' } as Recap)
21const rerunAtom = atom({ plugin: 'session-pane', key: 'rerun' } as const, { busy: false } as Rerun)
22const shownAtom = atom({ plugin: 'session-pane', key: 'shown' } as const, false) // the pane is open: the band and toasts stay quiet
23
24let m: SessionModel = empty()
25let dirty = false // an event changed m since the last flush
26let ready: Promise<void> | undefined // restore has run for this session
27let early: Seen[] | undefined = [] // events seen before restore finished, folded again onto what it restores
28let sid: string | undefined
29let shellSince: number | undefined // when the earliest Bash call that finished since the last scan began
30let shellUntil = 0 // when the latest of them ended
31let endedSid: string | undefined // the session a /clear or a resume ended: restore waits until the id has moved on
32let cleared = false // the session being restored began with a /clear: empty, so every event since is in early
33let told = new Set<string>() // alertsOf keys already toasted and still holding
34let goalQueue: { text: string; from: string }[] = [] // your messages the goal has not seen yet, newest last
35let goalBusy = false
36let turnOwed = false // a turn ended and its window is not yet recorded for the turns-left estimate
37let timer: Timer | undefined
38
39const KEEP_SESSIONS = 20 // snapshots kept in $.store; the oldest goes when a new session writes its first
40const SLACK_MS = 2000 // a file's mtime may fall this far outside the calls' span and still count as written by them
41const MAX_STATS = 200 // dirty files looked at per scan: a repo with thousands untracked costs no more than this
42const REPLAYED = new Set(['prompt.submit', 'tool.call']) // what replay() rebuilds from a transcript
43const MAX_GOAL_QUEUE = 5 // messages one goal call reads: older ones are what the newest build on
44const MAX_EARLY = 500 // events held while restore can't run (session.id failing): enough for a slow start, not a leak
45const snapKey = (id: string) => `snap:${id}`
46
47// === CAPTURE ===
48// The * hook: fold the event in before the chain runs and again with its result. It never changes e or the result, never
49// writes state (a redraw is an event: writing from here would loop), and a failure of its own never reaches the chain.
50// Declared at the top level: the loader only takes a hook it can see declared there
51async function capture($: EngineInterface, e: unknown, next: any) {
52 if (next.is('engine.create', e)) return next(e) // $ is the empty table there
53 const ev: string = next.event
54 const origin = next.origin
55 // our own $ calls (model.complete, store.set, fs.read) and other plugins' polling never move the pane
56 if (origin.plugin === PANE || isNoise(ev) || (origin.plugin !== 'engine' && isQuery(ev))) return next(e)
57 const t0 = Date.now()
58 fold({ ev, ph: 'enter', who: whoOf(ev, origin, e), e, t: t0 })
59 let r: unknown
60 try { r = await next(e) } catch (err) {
61 fold({ ev, ph: 'exit', who: whoOf(ev, origin, e, 'exit'), e, r: undefined, t: Date.now() }) // a thrown call still stops waiting
62 throw err
63 } finally {
64 if (ev === 'tool.call' && (e as any)?.tool === 'Bash') { shellSince = Math.min(shellSince ?? t0, t0); shellUntil = Date.now() } // scanned by the clock, never here
65 }
66 fold({ ev, ph: 'exit', who: whoOf(ev, origin, e, 'exit'), e, r, t: Date.now() })
67 return r
68}
69
70function fold(x: Seen) {
71 try {
72 if (early) { early.push(x); if (early.length > MAX_EARLY) early.shift() }
73 const worthy = x.ev === 'prompt.submit' && x.who === 'you' && goalWorthy(m, String(x.e?.text ?? '')) // before reduce drafts a goal
74 if (!reduce(m, x)) return
75 dirty = true
76 // queued, never asked here: the clock asks, and only while the pane is open (askGoal)
77 if (worthy) goalQueue = queueGoal(goalQueue, { text: m.lastPrompt ?? '', from: m.prompts === 1 ? 'Your opening message' : `Your message ${m.prompts}` })
78 if (x.ev === 'turn.complete' && !x.e?.agentId) turnOwed = true // a main turn ended, not a subagent's: the clock reads its window
79 } catch {}
80}
81
82// === FLUSH ===
83// Brings back this session's model: from $.state after a hot reload (it keeps the diffs), else the $.store snapshot after a
84// restart, else the transcript of a resumed session. Events seen meanwhile are folded again on top
85async function restore($: EngineInterface): Promise<void> {
86 const id = await $.session.id()
87 if (id === endedSid) throw new Error('the ended session is still current') // the next flush tries again
88 let got: SessionModel | undefined
89 const held = await read($, modelAtom).catch(() => undefined)
90 if (held?.sessionId === id) got = { ...empty(), ...held } // a field this load added is missing from what the last one held
91 if (!got) {
92 const snap = await $.store.get(snapKey(id)).catch(() => undefined) as SessionModel | undefined
93 if (snap && typeof snap === 'object' && snap.sessionId === id) got = { ...empty(), ...snap }
94 }
95 let replayed = false
96 if (!got) {
97 got = empty()
98 const msgs = cleared ? [] : await $.session.messages().catch(() => [])
99 if (msgs.length) { replay(got, msgs); replayed = true }
100 }
101 got.sessionId = id
102 // the transcript, read just now, already holds the prompts and tool calls seen meanwhile: folded again they would count twice
103 for (const x of early ?? []) if (!replayed || !REPLAYED.has(x.ev)) reduce(got, x)
104 early = undefined
105 cleared = false
106 m = got
107 sid = id
108 dirty = true
109 told = new Set(alertsOf(m).keys()) // what held before a restart or a resume was announced then, or is stale news now
110 // a draft never condensed (a replayed transcript, a call cut short by a restart) is asked about when the pane is next open
111 const g = m.goal
112 if (g?.isDraft && !goalQueue.some(q => q.text === g.text)) goalQueue = [{ text: g.text, from: g.from }, ...goalQueue]
113 // per session: the session after a /clear or a resume needs it again
114 await $.command.register({ name: 'session-pane', description: 'Show or hide the session pane: your goal, changes so far and whether checks still hold, what needs you, and context.', immediate: true })
115 .catch(err => { $.ui.log(`session-pane: /session-pane could not be registered: ${err}`) })
116}
117
118let flushing: Promise<void> | undefined
119async function flushOnce($: EngineInterface): Promise<void> {
120 await (ready ??= restore($).catch(() => { ready = undefined })) // failed (session.id rejected): the next flush tries again
121 if (!sid) return
122 if (shellSince !== undefined) {
123 const since = shellSince
124 shellSince = undefined
125 // each at its own mtime, not the scan's: a check that ran after the edit stays current
126 for (const f of await shellEdits($, since, shellUntil).catch(() => [])) if (mergeShellEdits(m, [f.path], f.t)) dirty = true
127 }
128 if (turnOwed) {
129 turnOwed = false
130 // 'summary' counts locally from the last response, no request sent; the threshold is in the same tokens as the total
131 const b = (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown
132 if (b) { recordTurn(m, b.totalTokens, b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined); dirty = true }
133 }
134 if (goalQueue.length && !goalBusy && await read($, shownAtom).catch(() => false)) void askGoal($)
135 if (!dirty) return
136 dirty = false
137 const plain = JSON.parse(JSON.stringify(m)) as SessionModel // a copy: the live model keeps changing under the write
138 await update($, modelAtom, () => plain)
139 await announce($)
140 await remember($, snapshotOf(plain))
141}
142// Which files a shell command wrote is not in any event: the Bash call carries its command and output only. So, as a
143// guess: every modified or untracked (not ignored) file in the session's repo whose mtime falls between the first
144// scanned call's start and the last one's end. A write by someone else in that window is credited to the session; a
145// repo other than the session's is not looked at. No git, no repo or no $.process (a surface other than the CLI): nothing
146async function shellEdits($: EngineInterface, since: number, until: number): Promise<{ path: string; t: number }[]> {
147 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
148 if (top.exitCode !== 0) return []
149 const root = top.stdout.trim()
150 const ls = await $.process.run(['git', 'ls-files', '-z', '-m', '-o', '--exclude-standard'], { cwd: root, timeoutMs: 10_000 })
151 if (ls.exitCode !== 0) return []
152 const paths = [...new Set(ls.stdout.split('\0').filter(Boolean))].slice(0, MAX_STATS).map(p => `${root}/${p}`)
153 const stats = await Promise.all(paths.map(p => $.fs.stat(p).then(s => s.mtimeMs, () => 0)))
154 return paths.map((path, i) => ({ path, t: stats[i] ?? 0 })).filter(f => f.t >= since - SLACK_MS && f.t <= until + SLACK_MS)
155}
156
157const flush = ($: EngineInterface) => flushing ??= flushOnce($).finally(() => { flushing = undefined })
158
159async function remember($: EngineInterface, snap: SessionModel) {
160 if (!sid) return
161 await $.store.set(snapKey(sid), snap)
162 const recent = ((await $.store.get('recent').catch(() => undefined)) as string[] | undefined) ?? []
163 if (recent[0] === sid) return
164 const next = [sid, ...recent.filter(x => x !== sid)]
165 for (const old of next.slice(KEEP_SESSIONS)) await $.store.delete(snapKey(old)).catch(() => {})
166 await $.store.set('recent', next.slice(0, KEEP_SESSIONS))
167}
168
169// === GOAL ===
170// The oldest message (often the one that set the goal) and the newest after it: what one call reads
171const queueGoal = (q: typeof goalQueue, x: (typeof goalQueue)[number]) => { const n = [...q, x]; return n.length > MAX_GOAL_QUEUE ? [n[0]!, ...n.slice(-(MAX_GOAL_QUEUE - 1))] : n }
172// The goal costs nothing while the pane is hidden: messages queue, and one call condenses all of them once the pane is
173// open (on opening, then at most one per clock tick while it stays open). A failed call leaves the goal as it was
174async function askGoal($: EngineInterface) {
175 goalBusy = true
176 try {
177 while (goalQueue.length) {
178 const batch = goalQueue
179 goalQueue = []
180 const last = batch.at(-1)!
181 const at = sid
182 const r = await $.model.complete({ model: 'haiku', system: GOAL_SYSTEM, prompt: goalPrompt(m, batch.map(b => b.text)), maxTokens: GOAL_TOKENS, timeoutMs: 20_000 }).catch(() => undefined)
183 if (sid !== at) return // a /clear or a resume moved on: the answer is about the session before
184 if (r?.isAnswered && applyGoal(m, r.text, last.text, last.from)) dirty = true
185 }
186 } finally { goalBusy = false }
187}
188
189// === RECAP ===
190const RECAP_PROMPT = `Write a recap of this session so far, for continuing the work after older details are summarised.
191Use these headings, each with short plain bullet points: Goal; Preferences and constraints; Decisions made; Progress so far; Still to do.
192Only include what this conversation actually established. At most 250 words. No preamble.`
193
194async function draftRecap($: EngineInterface) {
195 const at = sid
196 await update($, recapAtom, () => ({ status: 'drafting', text: '' }))
197 // what the pane folded from this session's events: a seed for what a summary may have dropped, not the source of truth
198 const notes = recapNotes(m)
199 const prompt = notes ? `${RECAP_PROMPT}\n\nNotes the session pane kept, which may help:\n<notes>\n${notes.slice(0, 6000)}\n</notes>` : RECAP_PROMPT
200 const r = await $.model.fork({ prompt }).catch(err => ({ isAnswered: false as const, reason: String(err) }))
201 if (sid !== at) return // a /clear or a resume moved on: this recap is of the session before, never to be used on this one
202 const next: Recap = r.isAnswered ? { status: 'ready', text: r.text.trim() }
203 : { status: 'failed', text: '', note: r.reason === 'nothing-to-fork' ? 'Nothing to recap yet: send a message first.' : "Couldn't draft a recap. Try again in a moment." }
204 await update($, recapAtom, () => next)
205 // drafting takes a while: the person may have hidden the pane meanwhile
206 if (next.status === 'ready' && !(await read($, shownAtom))) $.ui.toast('Recap ready: /session-pane to review it')
207}
208
209async function useRecap($: EngineInterface) {
210 const r = await read($, recapAtom)
211 if (r.status !== 'ready') return
212 await update($, recapAtom, () => ({ ...r, status: 'applying' }))
213 const before = m.compactions
214 const res = await $.session.compact({ instructions: `Carry this recap, reviewed by the person, into the summary faithfully:\n\n${r.text}` }).catch(err => ({ skip: String(err) }))
215 // our own $ call: capture skips its events, so the compaction is folded here, unless capture saw it after all
216 if (!res.skip && m.compactions === before) fold({ ev: 'session.compact', ph: 'exit', who: 'engine', e: { trigger: 'plugin' }, r: res, t: Date.now() })
217 await update($, recapAtom, () => res.skip ? { ...r, status: 'failed', note: "The summary didn't run. Try again in a moment." } : { ...r, status: 'applied' })
218}
219
220// === RERUN ===
221// Stale checks run again on the person's press, each command as Claude ran it (only those kept unaltered: canRerun).
222// Through $.tool.call, so the permission check applies; capture skips our own calls, so each result is folded here
223async function rerunStale($: EngineInterface) {
224 const stale = splitChecks(m).stale.filter(c => c.canRerun)
225 const cmds = [...new Map(stale.map(c => [c.cmd, c.name])).entries()] // two checks in one command run it once
226 if (!cmds.length || (await read($, rerunAtom)).busy) return
227 await update($, rerunAtom, () => ({ busy: true }))
228 const at = sid
229 let note: string | undefined
230 for (const [command, name] of cmds) {
231 const e = { tool: 'Bash' as const, command, description: `Rerun ${name}` }
232 const r = await $.tool.call(e).catch(err => ({ deny: String(err) }))
233 if (sid !== at) return // a /clear or a resume moved on: the result is the session before's, and session.end reset busy
234 if (r.deny) { note = `${name} was not rerun: ${r.deny}`; break }
235 fold({ ev: 'tool.call', ph: 'exit', who: `plugin:${PANE}`, e: { ...e, tool_use_id: `rerun-${Date.now()}` }, r, t: Date.now() })
236 }
237 await flush($).catch(() => {}) // the checks redraw fresh with the button's return, not a clock tick later
238 await update($, rerunAtom, () => ({ busy: false, note, at: Date.now() }))
239}
240
241// === AMBIENT ===
242// A toast per alert as it appears, only between turns: a check that fails mid-turn is Claude's to fix, and a toast for it
243// would be stale before it was read. Held while the pane is open, which shows the same
244async function announce($: EngineInterface) {
245 const now = alertsOf(m)
246 for (const k of told) if (!now.has(k)) told.delete(k) // cleared: the next time it holds is news again
247 if (m.running || await read($, shownAtom).catch(() => false)) return
248 for (const [k, text] of now) if (!told.has(k)) { told.add(k); $.ui.toast(text) }
249}
250
251// === HOOKS ===
252export const register: Register = on => {
253 on('*', capture)
254
255 // Once per process and per fresh load of this module, never after a /clear or a resume: session.end starts those over.
256 // A reload finds its own session in $.state
257 on('session.start', async ($, e, next) => {
258 timer ??= $.clock.every(2000, () => { flush($).catch(() => {}) })
259 await (ready ??= restore($).catch(() => { ready = undefined }))
260 return next(e) // never opens the pane: only /session-pane does
261 })
262
263 on('session.end', async ($, e, next) => {
264 shellSince = undefined // the exit's time bound is short and shared: no git scan now
265 try { await flush($); await flush($) } catch {} // the second catches changes made while an in-flight flush was writing
266 if (e.reason === 'clear' || e.reason === 'resume') {
267 // the process goes on under a new session id and no session.start follows: the clock keeps running, and its next
268 // flush restores the new session once the id has moved on
269 endedSid = e.sessionId; cleared = e.reason === 'clear'
270 m = empty(); ready = undefined; sid = undefined; early = []; goalQueue = []; dirty = false; turnOwed = false; told = new Set()
271 await Promise.all([update($, modelAtom, () => empty()), update($, viewAtom, () => 'overview'), update($, diffAtom, () => ''), update($, recapAtom, () => ({ status: 'idle', text: '' })), update($, rerunAtom, () => ({ busy: false }))]).catch(() => {})
272 } else { timer?.cancel(); timer = undefined }
273 return next(e)
274 })
275
276 on('command.run', { command: 'session-pane' }, async $ => {
277 const up = (await $.ui.panes()).some(p => p.id === PANE)
278 if (up) { await $.ui.close({ id: PANE }); await update($, shownAtom, () => false); return { text: 'Session pane hidden' } }
279 // docked, the default share is far wider than the ~44 columns the pages are laid out for and squeezes the transcript;
280 // 48 leaves a few over, so a long label wraps less. A request only: a width the person dragged the dock to wins
281 await $.ui.open({ id: PANE, title: TITLE, focus: true, columns: 48 })
282 await update($, shownAtom, () => true)
283 await flush($).catch(() => {}) // after the open: its git scan can take seconds, and its modelAtom write redraws the open pane
284 if (goalQueue.length && !goalBusy) void askGoal($) // what was said while it was hidden, in one call
285 return { text: 'Session pane shown' }
286 })
287
288 on('ui.close', async ($, e, next) => {
289 if (e.id === PANE) await Promise.all([update($, viewAtom, () => 'overview'), update($, shownAtom, () => false)]).catch(() => {}) // reopened, it starts at the overview
290 return next(e)
291 })
292
293 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
294 const el = $.ui.resolve(e) as Els
295 const [model, view, help, diff, recap, rerun] = await Promise.all([read($, modelAtom), read($, viewAtom), read($, helpAtom), read($, diffAtom), read($, recapAtom), read($, rerunAtom)])
296 const usage = view === 'context' ? await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
297 : view === 'details' ? await $.session.usage().catch(() => undefined) : undefined
298 const name = view === 'details' ? await $.session.model().catch(() => undefined) : undefined
299 const act: Act = {
300 go: v => { void update($, viewAtom, () => v) },
301 help: () => { void update($, helpAtom, h => !h) },
302 diff: path => { void update($, diffAtom, () => path).then(() => update($, viewAtom, () => 'diff')) },
303 draft: () => { void draftRecap($).catch(() => {}) },
304 use: () => { void useRecap($).catch(() => {}) },
305 discard: () => { void update($, recapAtom, () => ({ status: 'idle', text: '' })) },
306 rerun: () => { void rerunStale($).catch(() => update($, rerunAtom, () => ({ busy: false }))) },
307 }
308 return draw(el, { m: model, view, help, diff, recap, rerun, cols: e.props.bodyColumns ?? 44, usage, model: name }, act)
309 })
310
311 // The band above the prompt, shared with other mods: theirs stays, ours goes on top. Between turns only, and only while
312 // something needs the person; a survey holds the band alone
313 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
314 const theirs = await next(e)
315 if (e.props.hasSurvey || e.props.isWorking || await read($, shownAtom)) return theirs
316 const parts = bandOf(await read($, modelAtom))
317 if (!parts.length) return theirs
318 const { Box, Text } = $.ui.resolve(e) as Els
319 const line = (
320 // one line, the way in first: a narrow terminal cuts the last alert, never where to look. Dim dots between parts,
321 // since two warnings side by side share a colour
322 <Text key="session-pane-band" wrap="truncate-end">
323 <Text dimColor>/session-pane </Text>
324 {parts.flatMap((p, i) => [i ? <Text key={`d${i}`} dimColor> · </Text> : null, <Text key={`p${i}`} color={p.color}>{p.text}</Text>])}
325 </Text>
326 )
327 return theirs ? <Box flexDirection="column">{line}{theirs}</Box> : line
328 })
329
330 // During a turn the band is hidden, so the spinner carries the one thing that can't wait for the turn to end
331 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
332 const left = turnsLeft(await read($, modelAtom))
333 if (left == null || left > 2) return next(e)
334 // the engine's suffix may be '' (a message that ends in its own ellipsis): no separator dangling in front then
335 return next({ ...e, props: { ...e.props, suffix: [e.props.suffix, compactIn(left).toLowerCase()].filter(Boolean).join(' · ') } })
336 })
337}
338hooks/model.ts 577 lines1// The session as the pane reads it: one mutable SessionModel, folded from every event the `*` hook sees (reduce), plus
2// the pure readings the views draw from it (statusOf, checksOf, contextLabel, segmentsOf). No $ calls here: the hooks
3// module owns those, so everything below runs in a test as plain data.
4// Sections, in order (search `// ===`): TEXT, WHO, CHECKS, TOOLS, MODEL, REDUCE, READINGS, RESTORE
5
6import type { Agent, Est, FileChange, Goal, Hunk, SessionModel, Source, SourceNote } from '../types'
7export type { Agent, Est, FileChange, Goal, Hunk, SessionModel, Source, SourceNote }
8
9// === TEXT ===
10// best-effort secret scrub, not a guarantee: anything the pane shows or stores passes through it
11// The value after a secret-looking name (`OPENAI_KEY=`, `GH_PAT=`, `Authorization: Basic `, mysql's `-p<pass>`), then
12// well-known token shapes wherever they stand (`sk-…`, `ghp_…`, `xoxb-…`, `AKIA…`)
13const SECRET_AFTER = /(bearer\s+|basic\s+|\b\w*(token|secret|password|passwd|api[_-]?key|_key|_pat|credential)\w*[=:]\s*|--?(password|token|api-key)[= ]|\b(?:mysql\w*|mariadb)\b[^\n|;&]*?\s-p(?=[^\s-])|:\/\/[^:\/\s]+:)[^\s@'"]+/gi
14const SECRET_SHAPE = /\b(sk-|ghp_|gho_|ghs_|ghu_|github_pat_|xox[abpors]-|AKIA)[A-Za-z0-9_-]{12,}/g
15export const scrub = (s: string) => s.replace(SECRET_AFTER, '$1***').replace(SECRET_SHAPE, '$1***')
16const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
17export const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p
18const clip = (s: string, n: number) => s.length > n ? `${s.slice(0, n - 1)}…` : s
19const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
20
21// === WHO ===
22export type Origin = { readonly plugin: string; readonly tier: string }
23// prompt origins that are the person: typed (composer), `claude -p` (sdk), remote control (bridge); ui.* events use person
24const PERSON = new Set(['person', 'composer', 'sdk', 'bridge'])
25export function whoOf(ev: string, origin: Origin, e: any, ph: 'enter' | 'exit' = 'enter'): string {
26 if (ev.startsWith('classic.')) return 'hook' // the settings hooks' events
27 if (origin.plugin !== 'engine') return `plugin:${origin.plugin}` // its $ call raised it
28 if (PERSON.has(e?.origin?.kind)) return 'you'
29 const m = e?.message
30 if ((ev === 'turn.step' && ph === 'exit') || ev === 'tool.call' || (ev === 'session.append' && (m?.type === 'assistant' || m?.role === 'assistant'))) return 'claude'
31 return 'engine'
32}
33
34// Never folded: the screen's own events and the composer's keystrokes. session.measure is kept: it is the context fill
35const NOISE = new Set(['prompt.edit', 'prompt.autocomplete', 'prompt.suggest'])
36export const isNoise = (ev: string) => NOISE.has(ev) || ev.startsWith('ui.') || ev.startsWith('clock.') || ev.endsWith('.describe')
37// Read-only queries: another plugin polling them (from its render hook or a timer) must never redraw this pane, else each
38// redraw that makes one makes the next
39const QUERY = new Set(['ui.panes', 'session.id', 'session.cwd', 'session.root', 'session.repo', 'session.version', 'session.model', 'session.usage', 'session.surfaces', 'session.messages'])
40export const isQuery = (ev: string) => QUERY.has(ev) || /\.(get|list|keys|read|exists|stat)$/.test(ev)
41
42// === CHECKS ===
43// A Bash call's output read for a verdict: its exit code and the one to three lines that say what failed
44const OUTPUT_NOISE = /^\s*(npm (ERR!|error)\s*$|npm (ERR!|error) (code|path|errno|syscall|signal|command|cwd|A complete log|Lifecycle)|at .*(node_modules|node:internal)|[\d.]+%|.*[█░▓▒]{3}|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])/i
45const ERRLINE = /(error|exception)\b|\b(fatal|fail(ed|ure)?|panic|denied|rejected|conflict|not found|no such file|cannot|unable)\b|TS\d{4}/i // no \b before error: KeyError, TypeError
46const WRAP = new Set(['sudo', 'npx', 'bunx', 'exec', 'time', 'env', 'nice', 'timeout'])
47const RUNNER = /^(uv|poetry|pipenv|pdm|rye|hatch)$/ // `uv run pytest`: the program is what it runs
48const noCd = (s: string) => s.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, '') // a Bash command minus its leading `cd <dir> &&` / `;`
49export const progOf = (seg: string): string => { // `FOO=1 timeout 30 npx tsc -p .` -> tsc; `npm run test` -> npm test
50 const w = seg.trim().split(/\s+/).filter(t => t && !/^\w+=/.test(t))
51 while (w.length > 1 && (WRAP.has(w[0]!) || /^\d+[smh]?$/.test(w[0]!))) w.shift()
52 const p = (w[0] ?? '').split('/').pop() ?? ''
53 if (RUNNER.test(p) && w[1] === 'run' && w[2]) return progOf(w.slice(2).join(' '))
54 if (/^python[\d.]*$/.test(p) && w[1] === '-m' && w[2]) return w[2] // python -m pytest -> pytest
55 if (/^(pnpm|yarn|bun)$/.test(p) && (w[1] === 'exec' || w[1] === 'dlx') && w[2]) return progOf(w.slice(2).join(' '))
56 if ((p === 'make' || p === 'just') && w[1] && !w[1].startsWith('-')) return `${p} ${w[1]}`
57 if (/^(npm|pnpm|yarn|bun)$/.test(p) && w[1] && !w[1].startsWith('-')) return `${p} ${w[1] === 'run' ? w[2] ?? 'run' : w[1]}`
58 if (p === 'claude' && w[1] === 'plugin' && w[2]) return `claude plugin ${w[2]}` // a plugin's own test and validate
59 if ((p === 'cargo' || p === 'go') && w[1]) return `${p} ${w[1]}`
60 return p
61}
62const progsOf = (cmd: string) => noCd(cmd).split(/&&|\|\||[;|\n]/).map(progOf).filter(Boolean)
63export function insightOf(cmd: string | undefined, raw: string): { exit?: number; text: string } {
64 const m = raw.match(/^Exit code (\d+)/m) // the Bash tool's error text leads with it
65 const exit = m ? Number(m[1]) : undefined
66 const lines = raw.replace(ANSI, '').split('\n').map(l => l.trimEnd()).filter(l => l.trim() && !/^Exit code \d+$/.test(l.trim()) && !OUTPUT_NOISE.test(l))
67 const progs = cmd ? progsOf(cmd) : []
68 const last = progs.at(-1)
69 const known = progs.find(p => /^(vue-tsc|tsc|vitest|jest|pytest|git|cargo|go|eslint)\b|test$/.test(p)) ?? last ?? ''
70 let head = known
71 let pick: string[] = []
72 const ts = lines.filter(l => /error TS\d+/.test(l))
73 if (ts.length) {
74 const files = new Set(ts.map(l => l.split('(')[0]))
75 head = `${known || 'tsc'}: ${ts.length} error${ts.length > 1 ? 's' : ''} in ${files.size} file${files.size > 1 ? 's' : ''}`
76 pick = ts.map(l => l.replace(/^[^(]*\//, '').replace(/:\s*error (TS\d+):\s*/, ' $1 ')) // `src/x.ts(4,7): error TS2322: msg` -> `x.ts(4,7) TS2322 msg`
77 } else if (/test|vitest|jest|pytest/.test(known)) pick = lines.filter(l => /^\s*(FAIL|FAILED|✗|×|●)\s|\b\d+ failed\b|^\(fail\)/.test(l))
78 else if (known === 'git') pick = lines.filter(l => /^(fatal|error):|\[rejected\]|^CONFLICT/.test(l))
79 if (!pick.length) pick = lines.filter(l => ERRLINE.test(l))
80 if (!pick.length) pick = lines.slice(-3) // errors land at the end more often than at the start
81 return { exit, text: scrub([head, ...pick.slice(0, 3).map(l => l.trim())].filter(Boolean).join('\n')).slice(0, 300) }
82}
83
84// Which commands count as a check, under which plain name, and which output line says one failed. Matched per program of
85// the command, first hit wins; a command that is none of these (a build, a git call) is not a check
86const CHECK: [RegExp, string, RegExp][] = [
87 [/^(tsc|vue-tsc|mypy|pyright|cargo check|go vet|(npm|pnpm|yarn|bun) (typecheck|type-check|check-types|tsc)|(make|just) (typecheck|type-check))$/, 'Type check', /error TS\d+|^error(\[E\d+\])?:|\b[1-9]\d* errors?\b/],
88 [/^(eslint|ruff|biome|shellcheck|cargo clippy|claude plugin validate|(npm|pnpm|yarn|bun) lint|(make|just) lint)$/, 'Lint', /\b[1-9]\d* errors?\b/],
89 [/^(vitest|jest|pytest|mocha|playwright|cargo test|go test|claude plugin test|(npm|pnpm|yarn|bun) (test|t|test:\S+)|(make|just) tests?)$/, 'Tests', /^\s*(FAIL|FAILED)\b|^\s*[✗×]\s|\b[1-9]\d* (failed|failing|fail)\b|^\(fail\)/],
90]
91// isLast: the check is the command's last program, so the command's exit code is the check's own
92function checkIn(cmd: string): { name: string; fail: RegExp; isLast: boolean } | undefined {
93 const progs = progsOf(cmd)
94 for (const [i, p] of progs.entries()) for (const [re, name, fail] of CHECK) if (re.test(p)) return { name, fail, isLast: i === progs.length - 1 }
95 return undefined
96}
97export const checkOf = (cmd: string): string | undefined => checkIn(cmd)?.name
98
99// === TOOLS ===
100// What kind of effect a tool call has: Look feeds "Files and references", Change adds to the changes, the rest are results
101const KIND: [RegExp, string][] = [
102 [/^(Bash|PowerShell)$/, 'Shell'], [/^(Read|Grep|Glob|LS|LSP|NotebookRead)$/, 'Look'], [/^(Edit|Write|NotebookEdit)$/, 'Change'],
103 [/^Web/, 'Web'], [/^(Agent|SendMessage|ListAgents|SubagentHandback|Task\w*)$/, 'Agent'], [/^Skill$/, 'Skill'], [/^mcp__/, 'MCP'],
104 [/^(EnterWorktree|ExitWorktree|ToolSearch|TodoWrite|EnterPlanMode|ExitPlanMode|AskUserQuestion|SendUserMessage|Monitor|Cron\w*|ScheduleWakeup|RemoteTrigger|ReadNotifications|PushNotification)$/, 'Control'],
105]
106export const kindOf = (tool: string) => KIND.find(([re]) => re.test(tool))?.[1] ?? 'Tool'
107const pathOf = (i: any): string => String(i?.file_path ?? i?.notebook_path ?? i?.path ?? '')
108
109// What a subagent's tool call is doing, in words, present tense. A Bash call's own description says it best
110export function doingOf(tool: string, i: any): string {
111 const s = (x: unknown) => scrub(oneLine(String(x ?? '')))
112 switch (tool) {
113 case 'Read': return `Reading ${base(pathOf(i))}`
114 case 'Edit': case 'NotebookEdit': return `Editing ${base(pathOf(i))}`
115 case 'Write': return `Writing ${base(pathOf(i))}`
116 case 'Grep': return `Searching for "${clip(s(i?.pattern), 40)}"`
117 case 'Glob': return 'Looking for files'
118 case 'Bash': {
119 const cmd = String(i?.command ?? '')
120 const c = checkOf(cmd)
121 return i?.description ? clip(s(i.description), 80) : c ? `Running ${c.toLowerCase()}` : `Running ${progsOf(cmd).at(-1) ?? 'a command'}`
122 }
123 case 'WebFetch': return `Reading ${s(i?.url).replace(/^https?:\/\//, '').split('/')[0]}`
124 case 'WebSearch': return `Searching the web for "${clip(s(i?.query), 40)}"`
125 case 'Agent': return `Working with a helper: ${clip(s(i?.description) || 'a task', 60)}`
126 case 'Skill': return `Using the ${s(i?.skill)} skill`
127 case 'AskUserQuestion': return 'Asking you a question'
128 case 'TodoWrite': return 'Updating its plan'
129 }
130 if (tool.startsWith('mcp__')) { const [, server, name] = tool.split('__'); return `Using ${server}: ${name ?? ''}`.trim() }
131 return `Using ${tool}`
132}
133
134// === MODEL ===
135export const empty = (): SessionModel => ({ prompts: 0, running: false, waits: {}, asking: {}, files: [], checks: [], lastEdit: 0, context: {}, turnTokens: [], est: { conversation: 0, files: 0, results: 0 }, sources: [], compactions: 0, agents: [], onPlan: false })
136
137const MAX_FILES = 50
138const MAX_SOURCES = 40
139const MAX_AGENTS = 20
140// A call's input, held only to match it to its permission request: a digest, never the content (a Write's whole file,
141// a command's secrets) in state
142const digest = (s: string) => { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return `${s.length}:${(h >>> 0).toString(36)}` }
143const MAX_HUNK_LINES = 400 // per file: the diff view shows the change, not a whole generated file
144
145// === REDUCE ===
146// One event as the `*` hook saw it. enter: before the chain ran (r undefined); exit: after, with its result
147export type Seen = { ev: string; ph: 'enter' | 'exit'; who: string; e: any; r?: any; t: number }
148
149function addSource(m: SessionModel, s: Source) {
150 m.sources = [s, ...m.sources.filter(x => x.key !== s.key)].slice(0, MAX_SOURCES)
151}
152function addFile(m: SessionModel, f: FileChange) {
153 const was = m.files.find(x => x.path === f.path)
154 const isNew = was ? was.isNew : f.isNew // a file this session created stays New through later edits
155 const hunks = capHunks([...(was?.hunks ?? []), ...(f.hunks ?? [])])
156 const byShell = f.byShell && (!was || was.byShell) // a tool edit's diff outranks a shell's mention
157 m.files = [{ path: f.path, isNew, t: f.t, ...(hunks.length ? { hunks } : {}), ...(byShell ? { byShell: true as const } : {}) }, ...m.files.filter(x => x.path !== f.path)].slice(0, MAX_FILES)
158}
159// The newest hunks up to the cap, oldest first: the latest change is the one to review. A single hunk over the cap keeps
160// its first lines rather than vanishing
161function capHunks(all: Hunk[]): Hunk[] {
162 const kept: Hunk[] = []
163 let n = 0
164 for (const h of [...all].reverse()) {
165 if (n + h.lines.length > MAX_HUNK_LINES) { if (!kept.length) kept.push({ ...h, lines: h.lines.slice(0, MAX_HUNK_LINES) }); break }
166 n += h.lines.length
167 kept.push(h)
168 }
169 return kept.reverse()
170}
171const hunksOf = (r: any): Hunk[] => Array.isArray(r?.structuredPatch) ? r.structuredPatch.map((h: any) => ({ oldStart: h.oldStart, newStart: h.newStart, lines: (h.lines ?? []).map((l: string) => scrub(String(l))) })) : []
172
173// The question an AskUserQuestion call puts, with its choices; the dialog stays the only place to answer
174export function questionOf(i: any): string {
175 const q = Array.isArray(i?.questions) ? i.questions[0] : undefined
176 if (!q) return 'Claude has a question for you'
177 const opts = Array.isArray(q.options) ? q.options.map((o: any) => o?.label).filter(Boolean) : []
178 return scrub(oneLine(`${q.question ?? ''}${opts.length ? ` (${opts.join(' / ')})` : ''}`))
179}
180
181// Folds one event into m. Returns whether anything the pane draws changed. Subagent calls (agentId) add changes and
182// checks; only the main conversation's calls count toward what the context holds
183export function reduce(m: SessionModel, x: Seen): boolean {
184 const { ev, ph, e, r, t } = x
185 if (ev === 'prompt.submit' && ph === 'exit' && x.who === 'you') {
186 const text = String(e?.text ?? '').trim()
187 if (!text || text.startsWith('/')) return false // a slash command is not a message to Claude
188 m.lastPrompt = scrub(text).slice(0, 2000)
189 // a free draft at once: the opening message's first sentence, until the small model condenses it
190 if (!m.goal) m.goal = { title: titleOf(m.lastPrompt), keep: [], from: 'Your opening message', text: m.lastPrompt, isDraft: true }
191 m.prompts++
192 m.est.conversation += text.length
193 return true
194 }
195 if (ev === 'turn.start' && ph === 'enter') { m.running = true; m.ended = undefined; return true }
196 if (ev === 'agent.spawn' && ph === 'exit' && r?.agentId) {
197 m.agents = [{ id: String(r.agentId), name: scrub(String(e?.description || e?.subagentType || 'Subagent')), type: e?.subagentType, status: 'running' as const, calls: 0, t }, ...m.agents].slice(0, MAX_AGENTS)
198 return true
199 }
200 // a second sign a subagent ended, should its turn.complete never come: else it would read as running all session
201 if (ev === 'classic.SubagentStop' && ph === 'enter' && e?.agent_id) return agentDid(m, String(e.agent_id), a => a.status === 'running' ? { ...a, status: 'done', doing: undefined } : a)
202 if (ev === 'turn.complete' && ph === 'enter') {
203 if (e?.agentId) {
204 const u = e?.usage
205 const tokens = u ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) : undefined
206 return agentDid(m, e.agentId, a => ({ ...a, status: e?.isAborted ? 'stopped' : 'done', doing: undefined, tokens: tokens || a.tokens }))
207 }
208 m.running = false; m.waits = {}; m.asking = {}
209 m.ended = e?.isAborted ? 'aborted' : e?.reason
210 m.est.conversation += String(e?.answer ?? '').length
211 return true
212 }
213 if (ev === 'tool.check' && ph === 'exit' && r?.decision === 'ask' && e?.tool_use_id) {
214 m.asking = { ...m.asking, [e.tool_use_id]: { tool: String(e.tool), input: digest(JSON.stringify(e.input)), text: `Allow: ${doingOf(String(e.tool), e.input)}?` } }
215 return false // nothing drawn yet: the classifier may answer it
216 }
217 // a permission request no settings hook answered: the dialog opens next. It names the tool and input, not the call,
218 // so it matches the asking call with the same input, else the only one asking for that tool
219 if (ev === 'classic.PermissionRequest' && ph === 'exit' && !r?.decision && !r?.block) {
220 const asks = Object.entries(m.asking)
221 const same = asks.filter(([, a]) => a.tool === e?.tool_name)
222 const hit = same.find(([, a]) => a.input === digest(JSON.stringify(e?.tool_input))) ?? (same.length === 1 ? same[0] : undefined)
223 if (!hit) return false
224 const { [hit[0]]: _, ...rest } = m.asking
225 m.asking = rest
226 m.waits = { ...m.waits, [hit[0]]: hit[1].text }
227 return true
228 }
229 if (ev === 'tool.call') return toolCall(m, x)
230 if (ev === 'prompt.context' && ph === 'exit') {
231 const files: any[] = r?.instructionFiles ?? e?.instructionFiles ?? []
232 for (const f of files) addSource(m, { key: `i:${f.path}`, group: 'instructions', label: String(f.path).split('/').slice(-2).join('/'), note: 'loaded', t })
233 return files.length > 0
234 }
235 if (ev === 'prompt.attachment' && ph === 'exit' && !e?.agentId) return attachment(m, String(e?.type ?? ''), r ? r.text : e?.text, t)
236 if (ev === 'session.measure' && ph === 'enter') {
237 const c = e?.context ?? {}
238 m.context = { percent: c.percent, window: c.window, tokens: c.tokens }
239 m.onPlan = Array.isArray(e?.rateLimits) && e.rateLimits.length > 0
240 if (typeof e?.cost?.usd === 'number') m.cost = e.cost.usd
241 return true
242 }
243 if (ev === 'session.compact' && ph === 'exit' && !e?.agentId && r && !r.skip && Array.isArray(r.messages) && e?.trigger !== 'precompute') {
244 m.compactions++
245 m.turnTokens = [] // the window shrank: growth measured before the summary says nothing about after it
246 // the summary is the first message of what the compaction leaves; there is no separate summary string
247 m.summary = scrub(String(r.messages[0]?.text ?? '')).slice(0, 4000) || undefined
248 m.est = { conversation: r.messages.reduce((s: number, msg: any) => s + String(msg?.text ?? '').length, 0), files: 0, results: 0 }
249 m.sources = m.sources.filter(s => s.key !== 'summary').map(s => s.group === 'files' ? { ...s, note: 'earlier' as const } : s)
250 addSource(m, { key: 'summary', group: 'conversation', label: 'Earlier conversation summary', note: 'summary', t })
251 return true
252 }
253 return false
254}
255
256function toolCall(m: SessionModel, { ph, who, e, r, t }: Seen): boolean {
257 const tool = String(e?.tool ?? '')
258 const id = String(e?.tool_use_id ?? '')
259 // Claude's own call in the main conversation: only its result is in the context. A subagent's call (agentId) or another
260 // plugin's $.tool.call still adds changes and checks
261 const main = !e?.agentId && who === 'claude'
262 if (ph === 'enter') {
263 if (tool === 'AskUserQuestion') m.waits = { ...m.waits, [id]: questionOf(e) }
264 if (e?.agentId && agentDid(m, e.agentId, a => ({ ...a, doing: doingOf(tool, e), calls: a.calls + 1 }))) return true
265 return tool === 'AskUserQuestion'
266 }
267 let changed = false
268 if (id in m.waits) { const { [id]: _, ...rest } = m.waits; m.waits = rest; changed = true }
269 if (id in m.asking) { const { [id]: _, ...rest } = m.asking; m.asking = rest }
270 if (!r || r.deny) return changed
271 const text = String(r.text ?? '')
272 const kind = kindOf(tool)
273 if (main && kind === 'Look') m.est.files += text.length
274 else if (main) m.est.results += text.length
275 if (kind === 'Change' && !r.isError && !r.result?.staged) {
276 const path = String(r.result?.filePath ?? pathOf(e))
277 if (path) {
278 addFile(m, { path, isNew: tool === 'Write' && r.result?.type === 'create', t, hunks: hunksOf(r.result) })
279 m.lastEdit = t
280 if (main) {
281 m.est.files += String(e?.content ?? e?.new_string ?? e?.new_source ?? '').length // what Claude wrote is in the window too
282 addSource(m, { key: `f:${path}`, group: 'files', label: path, note: 'full', t })
283 }
284 }
285 }
286 if (main && tool === 'Read' && !r.isError) {
287 const path = pathOf(e)
288 if (path) addSource(m, { key: `f:${path}`, group: 'files', label: path, note: e?.offset != null || e?.limit != null ? 'excerpt' : 'full', t })
289 }
290 if (tool === 'Bash') {
291 const cmd = String(e?.command ?? '')
292 const c = checkIn(cmd)
293 if (c) {
294 const i = insightOf(cmd, text)
295 // a pipeline or a `;` list exits with its last program's code: a check piped into tail, or followed by another
296 // command, is judged by what its output says instead
297 const ok = c.isLast ? !r.isError && !(i.exit && i.exit > 0) : !text.replace(ANSI, '').split('\n').some(l => c.fail.test(l))
298 const kept = scrub(cmd).slice(0, 200)
299 m.checks = [{ name: c.name, ok, line: ok ? '' : i.text, t, cmd: kept, ...(rerunnable(cmd, kept) ? { canRerun: true as const } : {}) }, ...m.checks.filter(x => x.name !== c.name)]
300 }
301 }
302 return true
303}
304
305// A check the pane may run again: the command is the check alone (after one `cd <dir> &&`), sets no variable that may
306// hold a secret, and is stored exactly as typed. `npm test && rm -rf dist` is never offered: a rerun would remove dist too
307function rerunnable(cmd: string, kept: string): boolean {
308 return kept === cmd && progsOf(cmd).length === 1 && !/(^|\s)\w+=/.test(noCd(cmd))
309}
310
311// One of this session's subagents changed; false when the id is none of them (a teammate, another plugin's agent)
312function agentDid(m: SessionModel, id: string, f: (a: Agent) => Agent): boolean {
313 const i = m.agents.findIndex(a => a.id === id)
314 if (i < 0) return false
315 m.agents = m.agents.map((a, j) => j === i ? f(a) : a)
316 return true
317}
318
319// Engine notes about one file, named in their first line: what they say about that file's place in the context
320const FILE_NOTE: Record<string, [SourceNote, RegExp]> = {
321 edited_text_file: ['changed', /^Note: (.+?) changed on disk/],
322 compact_file_reference: ['unrestored', /^Note: (.+?) was read before/],
323 read_truncation_notice: ['excerpt', /(\/\S+)/], // the first absolute path the notice names
324 file: ['full', /"file_path":"([^"]+)"/],
325}
326// text undefined: the chain left it out of the request, so it never entered the context
327function attachment(m: SessionModel, type: string, text: string | undefined, t: number): boolean {
328 if (!text) return false
329 if (type === 'nested_memory') {
330 m.est.files += text.length
331 const p = text.match(/^Contents of (.+?):?$/m)?.[1]
332 if (p) addSource(m, { key: `i:${p}`, group: 'instructions', label: p.split('/').slice(-2).join('/'), note: 'loaded', t })
333 return !!p
334 }
335 const n = FILE_NOTE[type]
336 if (!n) { m.est.conversation += text.length; return false } // reminders and listings: counted, not listed
337 m.est.files += text.length
338 const p = text.match(n[1])?.[1]
339 if (!p) return false
340 addSource(m, { key: `f:${p}`, group: 'files', label: p, note: n[0], t })
341 return true
342}
343
344// Files a shell command changed, from a list of absolute paths written outside the engine; merged as Edited, since a
345// command's edits carry no diff and no create flag. Returns whether any were new to the list
346export function mergeShellEdits(m: SessionModel, paths: string[], t: number): boolean {
347 let added = false
348 for (const p of paths.map(s => s.trim()).filter(s => s.startsWith('/'))) {
349 // a file already listed is still a fresh change: the checks run before it say nothing about it now
350 if (t > m.lastEdit) { m.lastEdit = t; added = true }
351 if (m.files.some(f => f.path === p)) continue
352 addFile(m, { path: p, isNew: false, t, byShell: true })
353 added = true
354 }
355 return added
356}
357
358// === READINGS ===
359export type Status = { kind: 'new' | 'working' | 'input' | 'failed' | 'review' | 'idle'; label: string; detail?: string }
360type Check = SessionModel['checks'][number]
361// fresh: run since the last change, so its verdict holds; stale: run before it, so it says nothing about the code now
362export function splitChecks(m: SessionModel): { fresh: Check[]; stale: Check[] } {
363 return { fresh: m.checks.filter(c => c.t >= m.lastEdit), stale: m.checks.filter(c => c.t < m.lastEdit) }
364}
365const failing = (m: SessionModel) => splitChecks(m).fresh.find(c => !c.ok)
366
367// What 'Right now' says. It is drawn only for what needs the person: a question waiting, a failed check, subagents still
368// running. What Claude is doing and what's next are left to the spinner and Claude Code's own todo list
369export function statusOf(m: SessionModel): Status {
370 const ask = Object.values(m.waits).at(-1)
371 if (ask) return { kind: 'input', label: 'Needs your input', detail: ask }
372 const bad = failing(m)
373 // no dialog is open for a failure: not "needs your input"
374 if (bad) return { kind: 'failed', label: m.running ? `${bad.name} failed, Claude is investigating` : `${bad.name} failed`, detail: bad.line }
375 if (m.running) return { kind: 'working', label: 'Working' }
376 // the turn ended but agents it started in the background go on: not waiting for you yet
377 const bg = m.agents.filter(a => a.status === 'running').length
378 if (bg) return { kind: 'working', label: `${bg} background agent${bg === 1 ? '' : 's'} working` }
379 if (!m.prompts && !m.files.length) return { kind: 'new', label: 'Ready when you are' }
380 if (m.ended === 'aborted') return { kind: 'idle', label: 'Stopped', detail: 'You interrupted the last response' }
381 if (m.ended === 'error') return { kind: 'idle', label: 'Stopped by an error', detail: 'The last response ended with an error' }
382 if (m.ended === 'refusal') return { kind: 'idle', label: 'Stopped', detail: 'Claude declined the last request' }
383 // never "Done" or "Ready": a stopped turn with changes proves neither that the work is finished nor that the checks are
384 // fresh, so it only says there is something to look at
385 if (m.files.length) return { kind: 'review', label: 'Waiting for you', detail: 'Changes available to review' }
386 return { kind: 'idle', label: 'Waiting for you' }
387}
388
389// Checks as words: never a blank checkmark. line: the verdict of those run since the last change, failures first;
390// rerun: the ones run before it, by name, on a line of their own so the doubt is not buried behind a pass. Worded
391// "Rerun needed: …" so it reads the same for one name or several
392export function checksOf(m: SessionModel): { line: string; failed: boolean; rerun: string } {
393 if (!m.checks.length) return { line: m.files.length ? "Checks haven't run yet" : '', failed: false, rerun: '' }
394 const { fresh, stale } = splitChecks(m)
395 const names = (cs: Check[]) => cs.map(c => c.name).join(', ')
396 const bad = fresh.filter(c => !c.ok)
397 const good = fresh.filter(c => c.ok)
398 const line = [bad.length ? `${names(bad)} failed` : '', good.length ? `${names(good)} passed` : ''].filter(Boolean).join('; ')
399 return { line, failed: bad.length > 0, rerun: stale.length ? `Rerun needed: ${names(stale)}` : '' }
400}
401
402// Nearly full: past this the pane offers a recap. Set below the point where the engine compacts on its own, so there is
403// room to review a recap before a summary happens anyway
404export const NEARLY_FULL = 85
405export function contextLabel(percent?: number): { label: string; text: string } {
406 if (percent == null) return { label: 'Room to begin', text: 'Nothing measured yet: the first response fills this in.' }
407 if (percent >= NEARLY_FULL) return { label: 'Nearly full', text: 'Older details will soon be summarised. A recap lets you choose what carries forward.' }
408 if (percent >= 50) return { label: 'Filling up', text: 'The conversation and material Claude can use right now.' }
409 return { label: 'Plenty of room', text: 'The conversation and material Claude can use right now.' }
410}
411
412// The four groups of the expanded bar. Instructions come from the engine's own rows (everything in use but Messages);
413// Messages is split by this pane's estimate, then scaled so the three sum to the engine's figure. The gap between the
414// unscaled estimate and the engine's Messages row is the confidence shown beside it
415export type Segment = { name: string; tokens: number; color: string; isEstimate: boolean }
416export const CHARS_PER_TOKEN = 4 // rough for English prose and code; only used to split, never shown as a count
417export const CLOSE_ENOUGH = 0.25 // estimate within 25% of the engine's Messages figure reads as "close"
418export function segmentsOf(est: Est, rows: readonly { name: string; tokens: number; kind: string }[] | undefined): { segments: Segment[]; confidence: 'close' | 'rough' | 'none'; gap?: number } {
419 const used = (rows ?? []).filter(r => r.kind === 'used')
420 const msgRow = used.find(r => r.name === 'Messages')
421 const instructions = used.filter(r => r !== msgRow).reduce((s, r) => s + r.tokens, 0)
422 const raw = [est.conversation, est.files, est.results].map(c => c / CHARS_PER_TOKEN)
423 const rawSum = raw.reduce((s, x) => s + x, 0)
424 const engine = msgRow?.tokens
425 const scale = engine != null && rawSum > 0 ? engine / rawSum : 1
426 const [conversation = 0, files = 0, results = 0] = raw.map(x => Math.round(x * scale))
427 const segments: Segment[] = [
428 { name: 'Instructions', tokens: instructions, color: 'permission', isEstimate: false },
429 { name: 'Conversation', tokens: conversation, color: 'claude', isEstimate: true },
430 { name: 'Files and references', tokens: files, color: 'planMode', isEstimate: true },
431 { name: 'Tool results', tokens: results, color: 'bashBorder', isEstimate: true },
432 ]
433 if (!engine) return { segments, confidence: 'none' }
434 const gap = Math.abs(rawSum - engine) / engine
435 return { segments, confidence: gap <= CLOSE_ENOUGH ? 'close' : 'rough', gap }
436}
437
438// Turns left before auto-compact: the room to the threshold over the median growth per turn, the median so one big
439// file read doesn't swing it. Undefined whenever it can't say (auto-compact off or unread, fewer than two growths seen,
440// a window not growing): the pane then shows nothing, never a guessed 0. 0 means the next turn may compact
441const TURNS_KEPT = 6
442export function recordTurn(m: SessionModel, tokens: number, compactAt: number | undefined): void {
443 m.turnTokens = [...m.turnTokens, tokens].slice(-TURNS_KEPT)
444 m.compactAt = compactAt
445}
446export function turnsLeft(m: SessionModel): number | undefined {
447 const t = m.turnTokens
448 if (m.compactAt == null || t.length < 3) return undefined
449 const grew = t.slice(1).map((x, i) => x - t[i]!).sort((a, b) => a - b)
450 const mid = grew.length / 2
451 const median = grew.length % 2 ? grew[Math.floor(mid)]! : (grew[mid - 1]! + grew[mid]!) / 2
452 if (!(median > 0)) return undefined
453 const left = Math.floor((m.compactAt - t.at(-1)!) / median)
454 return Number.isFinite(left) ? Math.max(0, left) : undefined
455}
456// The window after each of your turns as a row of block characters, full height at where auto-compact starts (else the
457// window's size): how fast it fills, at a glance. Empty under two points
458const SPARK = '▁▂▃▄▅▆▇█'
459export function sparkOf(m: SessionModel): string {
460 const top = m.compactAt ?? m.context.window
461 if (m.turnTokens.length < 2 || !top) return ''
462 return m.turnTokens.map(t => SPARK[Math.min(SPARK.length - 1, Math.max(0, Math.round((t / top) * SPARK.length) - 1))]).join('')
463}
464// Claude Code calls it auto-compact; the pane says what happens to you: older messages get summarised
465export const compactIn = (left: number) => left === 0 ? 'Summarising any moment' : `Summarising in ~${left} turn${left === 1 ? '' : 's'}`
466
467// === AMBIENT ===
468// What the session says while the pane is hidden: only what needs the person, never progress. The band above the prompt
469// shows these parts as they stand; a toast announces an alert once, when it appears
470export type Part = { text: string; color?: string }
471const DOC_FILE = /\.(md|mdx|txt|rst)$/i // a change to prose alone owes no check
472const names = (cs: Check[]) => cs.map(c => c.name).join(', ')
473export function bandOf(m: SessionModel): Part[] {
474 const { fresh, stale } = splitChecks(m)
475 const bad = fresh.filter(c => !c.ok)
476 const left = turnsLeft(m)
477 const p = m.context.percent
478 const unchecked = !m.checks.length && m.files.filter(f => !DOC_FILE.test(f.path)).length
479 const parts: (Part | undefined)[] = [
480 unchecked ? { text: `${unchecked} file${unchecked === 1 ? '' : 's'} changed, no checks run`, color: 'warning' } : undefined,
481 bad.length ? { text: `✗ ${names(bad)} failed`, color: 'error' } : undefined,
482 stale.length ? { text: `Rerun needed: ${names(stale)}`, color: 'warning' } : undefined,
483 p != null && p >= NEARLY_FULL ? { text: `Context ${Math.round(p)}% used: prepare a recap`, color: 'warning' }
484 : left != null && left <= 2 ? { text: compactIn(left), color: 'warning' } : undefined,
485 ]
486 return parts.filter((x): x is Part => !!x)
487}
488// key -> toast text. A key stays while its alert holds, so a failure that outlives several turns is announced once
489export function alertsOf(m: SessionModel): Map<string, string> {
490 const out = new Map<string, string>()
491 for (const c of splitChecks(m).fresh) if (!c.ok) out.set(`fail:${c.name}`, `${c.name} failed: /session-pane shows what failed`)
492 const p = m.context.percent
493 if (p != null && p >= NEARLY_FULL) out.set('full', `Context ${Math.round(p)}% used: /session-pane can prepare a recap before older messages are summarised`)
494 return out
495}
496
497// === RESTORE ===
498// What $.store keeps per session: the model less its diffs (the store is small; a diff is the transcript's to keep)
499// A subagent running when the snapshot was taken is stopped by the time anything restores it: a restart ends it
500export const snapshotOf = (m: SessionModel): SessionModel => ({ ...m, files: m.files.map(({ hunks, ...f }) => f), running: false, waits: {}, asking: {},
501 agents: m.agents.map(a => a.status === 'running' ? { ...a, status: 'stopped' as const, doing: undefined } : a) })
502
503// A resumed session with no snapshot: rebuilt from the transcript. Prompts, tool calls and their stored results, in order;
504// what the transcript does not hold (the context fill, permission waits) stays empty until the next event
505export function replay(m: SessionModel, msgs: readonly { role: string; text: string; toolUses?: readonly any[]; toolResults?: readonly unknown[] }[]): void {
506 let t = 1
507 for (const msg of msgs) {
508 // a user row that opens with a tag is the engine's (a command's output, a reminder), not something you typed
509 if (msg.role === 'user' && !msg.toolResults?.length && msg.text && !/^\s*</.test(msg.text)) reduce(m, { ev: 'prompt.submit', ph: 'exit', who: 'you', e: { text: msg.text }, t: t++ })
510 if (msg.role !== 'assistant') continue
511 m.est.conversation += msg.text.length
512 for (const u of msg.toolUses ?? []) {
513 const e = { ...u.input, tool: u.tool, tool_use_id: u.tool_use_id }
514 if (u.text != null || u.result != null) reduce(m, { ev: 'tool.call', ph: 'exit', who: 'claude', e, r: { result: u.result, text: u.text, ...(u.isError ? { isError: true } : {}) }, t: t++ })
515 }
516 }
517}
518
519// === GOAL ===
520// The goal's title until the small model answers, and when it can't: the prompt's first sentence, cut at a word
521export function titleOf(text: string, words = 6): string {
522 const s = oneLine(text).split(/(?<=[.!?])\s/)[0]!.replace(/[.!?]+$/, '')
523 const w = s.split(' ')
524 return w.length > words ? `${w.slice(0, words).join(' ')}…` : s
525}
526
527// A message that may move the goal: any before the goal is condensed, after that only one of three words or more. "yes",
528// "go on", "do it" never cost a call
529export const goalWorthy = (m: SessionModel, text: string) => !m.goal || m.goal.isDraft || text.trim().split(/\s+/).length >= 3
530
531// One call condenses every message sent since the last one (the pane asks only while it is open), so it is kept short:
532// a terse system prompt, messages clipped, a small answer
533export const GOAL_SYSTEM = `Track what a person wants done in a coding session. Reply with JSON only:
534{"changed":bool,"title":str,"keep":[str],"done":str}
535changed: these messages set or replace the goal. title: the goal in at most 6 words, sentence case, no full stop; repeat the current one when unchanged.
536keep: constraints stated in these messages, each under 8 words; [] if none. done: what finished looks like, one short sentence; "" if unclear.`
537export const GOAL_TOKENS = 150
538const GOAL_CHARS = 3000 // of the messages, newest kept: enough to say what is wanted
539
540export function goalPrompt(m: SessionModel, texts: string[]): string {
541 const g = m.goal
542 const now = g && !g.isDraft ? `Current goal: ${g.title}` : 'No goal yet.'
543 let room = GOAL_CHARS
544 const kept = texts.slice().reverse().map(t => { const c = t.slice(0, Math.max(0, room)); room -= c.length; return c }).filter(Boolean).reverse()
545 return `${now}\n${kept.map(t => `<message>${t}</message>`).join('\n')}`
546}
547
548const MAX_KEEP = 8 // the goal's constraints: the newest, a list one reads at a glance
549// Folds the small model's reply into the goal. from: which message it was ("Your opening message", "Your message 3").
550// Returns whether the goal changed
551export function applyGoal(m: SessionModel, reply: string, text: string, from: string): boolean {
552 let j: any
553 try { j = JSON.parse(reply.match(/\{[\s\S]*\}/)?.[0] ?? '') } catch { return false }
554 if (!j || typeof j.title !== 'string' || !j.title.trim()) return false
555 const keep = (Array.isArray(j.keep) ? j.keep : []).filter((k: unknown) => typeof k === 'string' && k.trim()).map((k: string) => ({ text: scrub(clip(k.trim(), 80)), from }))
556 const title = scrub(clip(j.title.trim().replace(/\.$/, ''), 60))
557 const done = typeof j.done === 'string' && j.done.trim() ? scrub(clip(j.done.trim(), 200)) : undefined
558 if (!m.goal || m.goal.isDraft || j.changed === true) {
559 const opening = !m.goal || m.goal.isDraft
560 m.goal = { title, keep: [...(opening ? [] : m.goal?.keep ?? []), ...keep].slice(-MAX_KEEP), done: done ?? m.goal?.done, from: opening ? m.goal?.from ?? from : from, text: opening ? m.goal?.text ?? text : scrub(text).slice(0, 2000), isDraft: false }
561 return true
562 }
563 if (!keep.length) return false
564 m.goal = { ...m.goal, keep: [...m.goal.keep, ...keep.filter((k: { text: string }) => !m.goal!.keep.some(x => x.text.toLowerCase() === k.text.toLowerCase()))].slice(-MAX_KEEP) }
565 return true
566}
567
568// What the pane holds that a summary can drop: the goal and the files changed, folded from events, so a recap drafted
569// after an earlier compaction still names them. '' when there is nothing yet
570export function recapNotes(m: SessionModel): string {
571 const g = m.goal
572 return [
573 ...(g ? [`Goal: ${g.title}`, ...g.keep.map(k => `Keep in mind: ${k.text}`), ...(g.done ? [`Done when: ${g.done}`] : [])] : []),
574 ...(m.files.length ? ['Files changed:', ...m.files.map(f => `- ${f.path}${f.isNew ? ' (new)' : ''}`)] : []),
575 ].join('\n')
576}
577hooks/views.tsx 420 lines1import type { Elements, RenderChildren, SessionUsage } from 'claude-code'
2import type { Check, Recap, Rerun, View } from '../types'
3import { compactIn, contextLabel, splitChecks, NEARLY_FULL, segmentsOf, sparkOf, statusOf, turnsLeft, type Agent, type FileChange, type Segment, type SessionModel, type Source, type Status } from './model'
4
5// The pane's pages as trees. Pure: everything they draw comes in through Page, and every press goes out through Act.
6// Words before metrics: no token counts on the overview, a label beside every colour, nothing that animates.
7// One device on every page: a section is a titled rule across the column (`── Changes ────── 2 files ──`) with a blank
8// line above it, so the eye finds the blocks by their heads and the space between them. Only 'Right now' draws its rule
9// heavy and in its status colour, and only when it needs a look (a failure, a question); every other rule is dim. A subpage opens with its title and the way back on one row
10
11export type Els = Elements['terminal']
12export type Act = {
13 go: (v: View) => void
14 help: () => void
15 diff: (path: string) => void
16 draft: () => void
17 use: () => void
18 discard: () => void
19 rerun: () => void
20}
21export type Page = { m: SessionModel; view: View; help: boolean; diff: string; recap: Recap; rerun: Rerun; cols: number; usage?: SessionUsage; model?: string }
22
23const MAX_LIST = 8 // per group on a detail page; the rest is counted
24const MAX_FILES = 20 // on the Changes page: more than a list one reads; the rest is counted
25const MAX_AGENTS_NOW = 4 // running subagents named under Right now; the rest is counted
26const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
27const len = (s: string) => [...s].length
28// a path cut from the left, so the file name stays: …/src/components/confirm.tsx
29const tail = (p: string, n: number) => p.length <= n ? p : `…${p.slice(p.length - n + 1)}`
30// the folder every path shares, so the list shows what differs: '/w/src' for /w/src/a.ts and /w/src/b/c.ts
31const commonDir = (paths: string[]) => {
32 const parts = paths.map(p => p.split('/').slice(0, -1))
33 const first = parts[0] ?? []
34 let n = 0
35 while (n < first.length && parts.every(p => p[n] === first[n])) n++
36 return first.slice(0, n).join('/')
37}
38
39// The status mark and its colour; the label beside it always says the same in words
40const MARK: Record<Status['kind'], [string, string | undefined]> = {
41 new: ['○', undefined], working: ['●', 'claude'], input: ['?', 'warning'], failed: ['✗', 'error'], review: ['○', undefined], idle: ['○', undefined],
42}
43
44const NOTE: Record<Source['note'], string> = {
45 full: 'full file', excerpt: 'excerpt', earlier: 'read before the summary; may need re-reading', unrestored: 'not restored after the summary',
46 changed: 'changed on disk since it was read', loaded: 'loaded', summary: 'summary of earlier messages',
47}
48
49const HELP: Record<string, string> = {
50 goal: 'What this session is for, condensed from your messages while this pane is open. To change it, say so in the conversation.',
51 now: 'Shown only when something needs you: a question waiting, a failed check, or subagents still running.',
52 context: 'How much of the space for this conversation is in use. When it fills, older details get summarised.',
53 changes: 'Files Claude changed in this session, and whether checks such as tests passed since the last change or need rerunning.',
54}
55
56// rate-limit windows by the engine's kind; one not listed shows its kind with spaces
57const AGENT: Record<Agent['status'], string> = { running: 'Running', done: 'Finished', stopped: 'Stopped' }
58const LIMIT: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit', spend_limit: 'Spend limit' }
59// a time as words relative to now ('2 hours ago', 'in 1 hour'): a full date makes you do the sum
60const ago = (t: number) => {
61 const min = Math.round((t - Date.now()) / 60_000)
62 const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
63 return Math.abs(min) < 60 ? rtf.format(min, 'minute') : Math.abs(min) < 48 * 60 ? rtf.format(Math.round(min / 60), 'hour') : rtf.format(Math.round(min / 1440), 'day')
64}
65// Code's diff format parses only `@@ -a,b +c,d @@` headers; without the counts the hunk is drawn as plain, uncoloured text
66const hunkHead = (h: { oldStart: number; newStart: number; lines: string[] }) => {
67 const n = (marks: string) => h.lines.filter(l => marks.includes(l[0] ?? '')).length
68 return `@@ -${h.oldStart},${n(' -')} +${h.newStart},${n(' +')} @@`
69}
70
71// how full the context is, in words after the figure: '62% used, filling up'
72const usedLine = (percent?: number) => percent == null ? '' : `${Math.round(percent)}% used, ${contextLabel(percent).label.toLowerCase()}`
73
74const UI_FILE = /\.(tsx|jsx|vue|svelte|css|scss|html)$/
75
76// Items joined by ' · ' on one line of the width, as many as fit whole; the rest counted (`a · b +2`)
77export function fitList(items: string[], width: number): string {
78 for (let n = items.length; n > 1; n--) {
79 const s = items.slice(0, n).join(' · ') + (n < items.length ? ` +${items.length - n}` : '')
80 if (len(s) <= width) return s
81 }
82 return items.length > 1 ? `${items[0]} +${items.length - 1}` : items[0] ?? ''
83}
84
85// Shares of a width in whole cells, by largest remainder: rounded one by one, four runs can sum past the width and push a
86// cell onto the next row. Never more than the width in all
87export function cells(shares: number[], width: number): number[] {
88 const raw = shares.map(s => Math.max(0, s) * width)
89 const out = raw.map(Math.floor)
90 let left = Math.min(width, Math.round(raw.reduce((a, b) => a + b, 0))) - out.reduce((a, b) => a + b, 0)
91 for (const i of raw.map((_, i) => i).sort((a, b) => (raw[b]! % 1) - (raw[a]! % 1))) { if (left-- <= 0) break; out[i]!++ }
92 return out
93}
94
95export function draw(el: Els, p: Page, act: Act) {
96 const { Box, Text, Button } = el
97 const w = Math.max(1, p.cols) // no floor of our own: a wider tree than the body would spill
98
99 // A section's head: `── Title ───── right ──`, filled to the column from w. The right-hand value goes first when the
100 // two don't fit, then the title is cut; never a wrapped rule. swatch: a coloured square before the title (a legend)
101 type Rule = { right?: string; color?: string; heavy?: boolean; swatch?: string; rightColor?: string }
102 const bareOf = (title: string, o: Rule) => 3 + (o.swatch ? 2 : 0) + len(title) + 1
103 // whether a right-hand value fits on the rule; a caller whose value must show puts it in the body when not
104 const fits = (title: string, o: Rule) => !!o.right && w - bareOf(title, o) - (len(o.right) + 4) >= 2
105 const section = (k: string, title: string, o: Rule = {}) => {
106 const ch = o.heavy ? '━' : '─'
107 const bare = bareOf(title, o)
108 const right = fits(title, o) ? o.right : undefined
109 const fill = Math.max(2, w - bare - (right ? len(right) + 4 : 0))
110 const line = { color: o.color, dimColor: !o.color }
111 return (
112 <Text key={k} wrap="truncate-end">
113 <Text {...line}>{ch.repeat(2)} </Text>
114 {o.swatch ? <Text color={o.swatch}>■ </Text> : null}
115 <Text bold>{title}</Text>
116 <Text {...line}> {ch.repeat(fill)}</Text>
117 {right ? <Text color={o.rightColor} dimColor={!o.rightColor}> {right} </Text> : null}
118 {right ? <Text {...line}>{ch.repeat(2)}</Text> : null}
119 </Text>
120 )
121 }
122 // a block: its head, its help line when help is on, its body
123 const block = (k: string, title: string, body: RenderChildren[], o: Rule = {}) => (
124 <Box key={k} flexDirection="column">
125 {section(`${k}-h`, title, o)}
126 {p.help && HELP[k] ? <Text key={`${k}-help`} dimColor italic wrap="wrap">{HELP[k]}</Text> : null}
127 {body}
128 </Box>
129 )
130 // a subpage's first row: its title, and the way back at the far end
131 const top = (title: string, to: View = 'overview', toLabel = 'Overview') => (
132 <Box key="top" flexDirection="row" justifyContent="space-between" width={w}>
133 <Text key="t" bold wrap="truncate-end">{title}</Text>
134 <Button key="back" label={`← ${toLabel}`} hotkey="b" plain onPress={() => act.go(to)} />
135 </Box>
136 )
137 const bar = (k: string, runs: { n: number; color?: string }[], width: number) => {
138 const used = runs.reduce((s, r) => s + r.n, 0)
139 return (
140 <Text key={k}>
141 {runs.filter(r => r.n > 0).map((r, i) => <Text key={`${k}${i}`} color={r.color}>{'█'.repeat(r.n)}</Text>)}
142 <Text dimColor>{'░'.repeat(Math.max(0, width - used))}</Text>
143 </Text>
144 )
145 }
146 // one check, the same mark and words on every page; full adds what failed under a failure
147 const check = (c: Check, stale: boolean, full: boolean) => stale
148 ? <Text key={`s-${c.name}`} wrap="wrap"><Text color="warning">–</Text> <Text dimColor>{c.name} {c.ok ? 'passed' : 'failed'} before the last change; rerun needed</Text></Text>
149 : <Box key={`c-${c.name}`} flexDirection="column">
150 <Text key="l" wrap="wrap"><Text color={c.ok ? 'success' : 'error'}>{c.ok ? '✓' : '✗'}</Text> {c.name} {c.ok ? 'passed' : 'failed'}</Text>
151 {!c.ok && full && c.line ? <Box key="f" paddingLeft={2}><Text dimColor wrap="wrap">{c.line}</Text></Box> : null}
152 </Box>
153
154 const m = p.m
155 const st = statusOf(m)
156 const ctx = contextLabel(m.context.percent)
157 const nearly = (m.context.percent ?? 0) >= NEARLY_FULL
158 const { fresh, stale } = splitChecks(m)
159 const used = usedLine(m.context.percent)
160 // the turns-left estimate in words; nothing at all when there is no estimate
161 const left = turnsLeft(m)
162 const compactLine = left == null ? null
163 : <Text key="turns" color={left <= 2 ? 'warning' : undefined} dimColor={left > 2}>{compactIn(left)}</Text>
164 const running = m.agents.filter(a => a.status === 'running')
165 const recapButton = nearly ? <Button key="recap" label="Prepare a recap" hotkey="p" variant="primary" onPress={() => act.go('recap')} /> : null
166
167 // === OVERVIEW ===
168 // Goal, then what needs you (only when something does), then what changed and whether it is verified (what a review
169 // needs, together), then context. Each block ends in the one press that opens its page
170 if (p.view === 'overview') {
171 const [mark, color] = MARK[st.kind]
172 const loud = st.kind === 'input' || st.kind === 'failed' // working is the usual state: lit, it would dull these two
173 const filled = m.context.percent == null ? 0 : Math.max(1, Math.round((m.context.percent / 100) * w))
174 return (
175 <Box flexDirection="column" width={w} gap={1}>
176 {/* two lines at most: the title, and the constraints on one dim line. A draft says so on the rule, not in a line of
177 its own; what finished looks like goes to a recap, not here */}
178 {block('goal', 'Goal', m.goal
179 ? [<Text key="goal-t" bold wrap="wrap">{m.goal.title}</Text>,
180 m.goal.keep.length ? <Text key="goal-k" dimColor wrap="truncate-end">{fitList(m.goal.keep.map(k => k.text), w)}</Text> : null]
181 : [<Text key="goal-none" dimColor wrap="wrap">Your goal appears here after your first message.</Text>],
182 { right: m.goal?.isDraft ? 'draft' : undefined })}
183 {/* only what needs you: a question, a failure, subagents still running. What Claude is doing and what's next are
184 the spinner's and the todo list's to show. Only a failure or a question lights the rule */}
185 {loud || running.length ? block('now', 'Right now', [
186 <Text key="now-l" wrap="wrap"><Text color={color}>{mark}</Text> <Text bold>{st.label}</Text></Text>,
187 loud && st.detail ? <Box key="now-d" paddingLeft={2}><Text wrap="wrap">{st.detail}</Text></Box> : null,
188 st.kind === 'input' ? <Box key="now-a" paddingLeft={2}><Text dimColor>Answer in the dialog</Text></Box> : null,
189 // subagents: what each is doing, no token counts (those are on Session details)
190 ...running.slice(0, MAX_AGENTS_NOW).map(a => <Box key={`ag-${a.id}`} paddingLeft={2}><Text wrap="truncate-end"><Text color="claude">◐</Text> {a.name}<Text dimColor>{a.doing ? ` · ${a.doing}` : ''}</Text></Text></Box>),
191 running.length > MAX_AGENTS_NOW ? <Box key="ag-more" paddingLeft={2}><Text dimColor>and {running.length - MAX_AGENTS_NOW} more agents</Text></Box> : null,
192 ], loud ? { color, heavy: true } : {}) : null}
193 {block('changes', 'Changes', m.files.length
194 ? [...fresh.map(c => check(c, false, false)),
195 ...stale.map(c => check(c, true, false)),
196 !m.checks.length ? <Text key="chk-none" dimColor>Checks haven't run yet</Text> : null,
197 <Button key="chg" label="Review changes" hotkey="r" plain onPress={() => act.go('changes')} />]
198 : [<Text key="chg-none" dimColor>No files changed yet</Text>],
199 { right: m.files.length ? plural(m.files.length, 'file') : undefined })}
200 {/* no colour until nearly full, so a near-empty bar reads as no warning; the bar takes the whole column */}
201 {block('context', 'Context', [
202 used && !fits('Context', { right: used }) ? <Text key="ctx-u" color={nearly ? 'warning' : undefined}>{used}</Text> : null,
203 m.context.percent == null ? <Text key="ctx-t" dimColor wrap="wrap">{ctx.text}</Text> : bar('ctx-bar', [{ n: filled, color: nearly ? 'warning' : undefined }], w),
204 compactLine,
205 nearly ? <Text key="ctx-n" dimColor wrap="wrap">{ctx.text}</Text> : null,
206 recapButton,
207 <Button key="ctx" label="What's included" hotkey="c" plain onPress={() => act.go('context')} />,
208 ], { right: used || undefined, rightColor: nearly ? 'warning' : undefined })}
209 <Box key="foot" flexDirection="column">
210 <Text key="foot-r" dimColor>{'─'.repeat(w)}</Text>
211 <Box key="foot-b" flexDirection="row" flexWrap="wrap" columnGap={3}>
212 <Button key="details" label="Session details" hotkey="d" plain onPress={() => act.go('details')} />
213 <Button key="help" label={p.help ? 'Hide help' : 'Show help'} hotkey="h" plain onPress={act.help} />
214 </Box>
215 </Box>
216 </Box>
217 )
218 }
219
220 // === CONTEXT ===
221 if (p.view === 'context') {
222 const window = p.usage?.context.window ?? m.context.window
223 const rows = p.usage?.context.breakdown?.categories
224 const seg = segmentsOf(m.est, rows)
225 const share = (n: number) => window ? n / window : 0
226 const runs = cells(seg.segments.map(s => share(s.tokens)), w).map((n, i) => ({ n, color: seg.segments[i]!.color }))
227 const pct = (n: number) => `${Math.round(share(n) * 100)}%`
228 const group = (g: Source['group']) => m.sources.filter(s => s.group === g)
229 // one group: a section whose head is its legend (colour, name, share), its sources listed under it
230 const list = (k: string, s: Segment | undefined, items: Source[], first?: string) => {
231 if (!s) return null
232 const shown = items.slice(0, MAX_LIST)
233 return (
234 <Box key={k} flexDirection="column">
235 {section(`${k}-h`, `${s.name}${s.isEstimate ? ' (estimate)' : ''}`, rows ? { swatch: s.color, right: pct(s.tokens) } : {})}
236 {first ? <Text key={`${k}-0`} dimColor wrap="wrap">{first}</Text> : null}
237 {/* label and note on one line when both fit, else the note goes under it: a path cut to fit a long note says nothing */}
238 {shown.flatMap(x => len(x.label) + len(NOTE[x.note]) + 3 <= w
239 ? [<Text key={`${k}-${x.key}`}>{x.label} <Text dimColor>({NOTE[x.note]})</Text></Text>]
240 : [<Text key={`${k}-${x.key}`}>{tail(x.label, w)}</Text>, <Box key={`${k}-${x.key}-n`} paddingLeft={2}><Text dimColor wrap="wrap">{NOTE[x.note]}</Text></Box>])}
241 {items.length > shown.length ? <Text key={`${k}-more`} dimColor>and {items.length - shown.length} more</Text> : null}
242 {!items.length && !first ? <Text key={`${k}-none`} dimColor>None yet</Text> : null}
243 </Box>
244 )
245 }
246 const segment = (name: string) => seg.segments.find(s => s.name === name)
247 const spark = sparkOf(m)
248 const confidence = seg.confidence === 'close'
249 ? `The split is an estimate from what this pane saw; it matches the engine's own total within ${Math.max(1, Math.round((seg.gap ?? 0) * 100))}%.`
250 : seg.confidence === 'rough'
251 ? "The split is a rough estimate: it differs from the engine's own total by more than a quarter, often because some of the conversation came before this pane loaded. The total is the engine's."
252 : 'The split appears after the first response.'
253 return (
254 <Box flexDirection="column" width={w} gap={1}>
255 {top('Context')}
256 <Box key="fill" flexDirection="column">
257 <Text key="u" bold color={nearly ? 'warning' : undefined}>{used || ctx.label}</Text>
258 {rows ? bar('bar', runs, w) : null}
259 {compactLine}
260 {spark ? <Text key="spark" color={nearly ? 'warning' : undefined}>{spark}</Text> : null}
261 {spark ? <Text key="spark-t" dimColor wrap="wrap">How full it was after each turn since the last summary; full height is where summarising starts.</Text> : null}
262 <Text key="t" dimColor wrap="wrap">{ctx.text} What's listed is available for the next response; reading a file once does not mean all of it stays available.</Text>
263 {recapButton}
264 </Box>
265 {list('i', segment('Instructions'), group('instructions'), "Claude Code's built-in instructions and tool descriptions")}
266 {list('c', segment('Conversation'), group('conversation'), m.prompts ? `Your ${plural(m.prompts, 'message')} and Claude's replies${m.compactions ? ' since the last summary' : ''}` : undefined)}
267 {list('f', segment('Files and references'), group('files'))}
268 {list('r', segment('Tool results'), [], 'Output of commands, searches and other tools Claude ran.')}
269 {m.summary
270 ? <Box key="sum" flexDirection="column">
271 {section('sum-h', 'Carried forward in the summary')}
272 <Text key="sum" wrap="wrap" dimColor>{m.summary.split('\n').slice(0, 8).join('\n')}</Text>
273 </Box>
274 : null}
275 <Text key="conf" dimColor italic wrap="wrap">{confidence}</Text>
276 </Box>
277 )
278 }
279
280 // === CHANGES ===
281 if (p.view === 'changes') {
282 const ui = m.files.some(f => UI_FILE.test(f.path))
283 // paths under the folder they share, named once above the list; one file keeps its whole path
284 const dir = m.files.length > 1 ? commonDir(m.files.map(f => f.path)) : ''
285 const rel = (path: string) => dir ? path.slice(dir.length + 1) : path
286 const row = (f: FileChange) => {
287 const by = f.byShell ? ' (by a command)' : ''
288 const label = `${f.isNew ? 'New ' : 'Edited'} ${tail(rel(f.path), Math.max(12, w - 8 - by.length))}${by}`
289 return f.hunks?.length
290 ? <Button key={`f-${f.path}`} label={label} plain onPress={() => act.diff(f.path)} />
291 : <Text key={`f-${f.path}`} dimColor wrap="truncate-end">{label}</Text>
292 }
293 return (
294 <Box flexDirection="column" width={w} gap={1}>
295 {top('Changes')}
296 <Box key="files" flexDirection="column">
297 {section('files-h', 'Files', { right: m.files.length ? plural(m.files.length, 'file') : undefined })}
298 {dir ? <Text key="dir" dimColor wrap="truncate-start">in {dir}</Text> : null}
299 {m.files.slice(0, MAX_FILES).map(row)}
300 {m.files.length > MAX_FILES ? <Text key="more" dimColor>and {m.files.length - MAX_FILES} more</Text> : null}
301 {!m.files.length ? <Text key="none" dimColor>No files changed yet</Text> : m.files.some(f => f.hunks?.length) ? <Text key="t" dimColor wrap="wrap">Select a file for its diff.</Text> : null}
302 </Box>
303 {/* whether the changes are verified is this block's to say */}
304 <Box key="checks" flexDirection="column">
305 {section('c-h', 'Checks')}
306 {!m.checks.length ? <Text key="c-none" dimColor>{m.files.length ? "Checks haven't run yet" : 'None run yet'}</Text> : null}
307 {fresh.map(c => check(c, false, true))}
308 {stale.map(c => check(c, true, true))}
309 {/* only a command kept exactly as run, and never mid-turn: a rerun while Claude edits measures nothing */}
310 {stale.some(c => c.canRerun) && !m.running
311 ? p.rerun.busy ? <Text key="c-busy" dimColor>Rerunning…</Text> : <Button key="rerun" label="Rerun stale checks" hotkey="e" onPress={act.rerun} />
312 : null}
313 {p.rerun.note && !m.checks.some(c => c.t > (p.rerun.at ?? 0)) ? <Text key="c-note" color="error" wrap="wrap">{p.rerun.note}</Text> : null}
314 </Box>
315 {ui
316 ? <Box key="ui" flexDirection="column">
317 {section('ui-h', 'Browser review')}
318 <Text key="ui-t" dimColor wrap="wrap">Not done. Checks don't show how the page looks; open it to see.</Text>
319 </Box>
320 : null}
321 </Box>
322 )
323 }
324
325 // === DIFF ===
326 if (p.view === 'diff') {
327 const f = m.files.find(x => x.path === p.diff)
328 const hunks = f?.hunks ?? []
329 const source = hunks.map(h => `${hunkHead(h)}\n${h.lines.join('\n')}`).join('\n')
330 const count = (mark: string) => hunks.reduce((s, h) => s + h.lines.filter(l => l[0] === mark).length, 0)
331 const { Code } = el
332 return (
333 <Box flexDirection="column" width={w} gap={1}>
334 {top(p.diff.split('/').pop() || p.diff, 'changes', 'Changes')}
335 <Box key="d" flexDirection="column">
336 {section('d-h', f?.isNew ? 'New file' : 'Edited', { right: source ? `${count('+')} added, ${count('-')} removed` : undefined })}
337 <Text key="p" dimColor wrap="truncate-start">{p.diff}</Text>
338 {source ? <Code key="code" source={source} format="diff" path={p.diff} wrap="wrap" /> : <Text key="none" dimColor>No diff kept for this file.</Text>}
339 </Box>
340 </Box>
341 )
342 }
343
344 // === RECAP ===
345 if (p.view === 'recap') {
346 const r = p.recap
347 const { Markdown } = el
348 return (
349 <Box flexDirection="column" width={w} gap={1}>
350 {top('Prepare a recap')}
351 <Box key="intro" flexDirection="column">
352 <Text key="t" dimColor wrap="wrap">A recap carries the goal, your preferences, decisions, progress and what's left into a fresh summary. Details it leaves out may need to be given again; a recap is not permanent memory.</Text>
353 {r.status === 'idle' || r.status === 'failed' ? <Button key="draft" label="Draft a recap" hotkey="d" variant="primary" onPress={act.draft} /> : null}
354 {r.status === 'failed' && r.note ? <Text key="err" color="error" wrap="wrap">{r.note}</Text> : null}
355 {r.status === 'drafting' ? <Text key="busy" dimColor>Drafting…</Text> : null}
356 </Box>
357 {r.text && r.status !== 'idle'
358 ? <Box key="draft-b" flexDirection="column">
359 {section('r-h', 'Draft', { right: r.status === 'applying' ? 'summarising' : r.status === 'applied' ? 'used' : undefined })}
360 <Markdown key="text" text={r.text} />
361 </Box>
362 : null}
363 {r.status === 'applying' ? <Text key="busy" dimColor>Summarising the conversation with this recap…</Text> : null}
364 {r.status === 'applied' ? <Text key="ok" wrap="wrap">The conversation was summarised with this recap.</Text> : null}
365 {r.status === 'ready'
366 ? <Box key="act" flexDirection="column">
367 <Text key="ask" dimColor wrap="wrap">Anything wrong or missing? Say it in the conversation and draft again.</Text>
368 <Box key="btn" flexDirection="row" gap={2}>
369 <Button key="use" label="Use this recap" hotkey="u" variant="primary" onPress={act.use} />
370 <Button key="discard" label="Discard" hotkey="x" onPress={act.discard} />
371 </Box>
372 </Box>
373 : null}
374 </Box>
375 )
376 }
377
378 // === DETAILS ===
379 const u = p.usage
380 const limits = u?.rateLimits ?? []
381 const cost = u?.cost?.usd ?? m.cost
382 // a dim label column and the value beside it: the label is read once, the value is what changes
383 const kv = (k: string, label: string, value: RenderChildren) => (
384 <Box key={k} flexDirection="row">
385 <Box key="l" width={14} flexShrink={0}><Text dimColor>{label}</Text></Box>
386 <Text key="v" wrap="wrap">{value}</Text>
387 </Box>
388 )
389 return (
390 <Box flexDirection="column" width={w} gap={1}>
391 {top('Session details')}
392 <Box key="s" flexDirection="column">
393 {section('s-h', 'Session')}
394 {p.model ? kv('model', 'Model', p.model) : null}
395 {u?.startedAt ? kv('start', 'Started', ago(u.startedAt)) : null}
396 {kv('prompts', 'Messages', `${m.prompts}${m.compactions ? ` (${plural(m.compactions, 'summary', 'summaries')})` : ''}`)}
397 </Box>
398 <Box key="u" flexDirection="column">
399 {section('u-h', 'Usage')}
400 {kv('ctx', 'Context', m.context.percent == null ? 'not measured yet' : `${Math.round(m.context.percent)}% used`)}
401 {limits.map(l => kv(`l-${l.kind}`, LIMIT[l.kind] ?? l.kind.replace(/_/g, ' '), <Text>{Math.round(l.percentUsed)}% used{l.resetsAt ? <Text dimColor>, resets {ago(Date.parse(l.resetsAt))}</Text> : null}</Text>))}
402 {/* money only where it means something: off a subscription, the session's cost is what the person pays */}
403 {!limits.length && !m.onPlan && cost != null ? kv('cost', 'Cost', `$${cost.toFixed(2)} so far`) : null}
404 </Box>
405 {m.agents.length
406 ? <Box key="a" flexDirection="column">
407 {section('a-h', 'Subagents', { right: plural(m.agents.length, 'agent') })}
408 {m.agents.slice(0, MAX_LIST).map(a => (
409 <Box key={`a-${a.id}`} flexDirection="column">
410 <Text key="n" wrap="truncate-end">{a.name}{a.type && a.type !== a.name ? <Text dimColor> ({a.type})</Text> : null}</Text>
411 <Box key="d" paddingLeft={2}><Text dimColor wrap="wrap">{AGENT[a.status]}, {plural(a.calls, 'tool call')}{a.tokens ? `, ${Math.round(a.tokens / 1000)}k tokens` : ''}{a.status === 'running' && a.doing ? ` · ${a.doing}` : ''}</Text></Box>
412 </Box>
413 ))}
414 {m.agents.length > MAX_LIST ? <Text key="a-more" dimColor>and {m.agents.length - MAX_LIST} more</Text> : null}
415 </Box>
416 : null}
417 </Box>
418 )
419}
420types/index.d.ts 55 lines1// The pane's state contract: the reduced session (model), which page is shown (view), whether the help lines are open
2// (help), the path the diff page shows (diff), the recap being drafted (recap), whether the pane is open (shown) and a rerun of stale checks (rerun). Self-contained: model.ts imports these
3export type Hunk = { oldStart: number; newStart: number; lines: string[] }
4export type FileChange = { path: string; isNew: boolean; t: number; hunks?: Hunk[]; byShell?: true }
5// canRerun: cmd is the command exactly as run (scrub and the length cap left it alone), so the pane may run it again
6export type Check = { name: string; ok: boolean; line: string; t: number; cmd: string; canRerun?: true }
7// A subagent this session started (agent.spawn), followed by the agentId its loop's events carry. tokens: its turn's
8// usage, known once it finished
9export type Agent = { id: string; name: string; type?: string; status: 'running' | 'done' | 'stopped'; doing?: string; calls: number; tokens?: number; t: number }
10// How much of a source the next response can see. full / excerpt: read in this window; earlier: read before a summary;
11// unrestored: the engine said it was not brought back; changed: edited on disk since it was read
12export type SourceNote = 'full' | 'excerpt' | 'earlier' | 'unrestored' | 'changed' | 'summary' | 'loaded'
13export type Source = { key: string; group: 'instructions' | 'files' | 'conversation'; label: string; note: SourceNote; t: number }
14// keep: what to keep in mind, each with the message it came from. done: what finished looks like. isDraft: the title is
15// the first sentence of your opening message, until the small model has condensed it
16export type Goal = { title: string; keep: { text: string; from: string }[]; done?: string; from: string; text: string; isDraft: boolean }
17export type Est = { conversation: number; files: number; results: number } // characters, since the last summary
18export type SessionModel = {
19 sessionId?: string // whose model this is: a restored value from another session is ignored
20 goal?: Goal
21 prompts: number // your prompts this session
22 lastPrompt?: string
23 running: boolean
24 ended?: 'answer' | 'aborted' | 'refusal' | 'error'
25 waits: Record<string, string> // tool_use_id -> what Claude is waiting on you for
26 // tool_use_id -> a call whose check said ask: put to the mode's decider, which is the auto-mode classifier as often as
27 // you. It becomes a wait once the permission request passes unanswered, as the dialog opens. input: a digest of the
28 // call's input, only for matching it to that request
29 asking: Record<string, { tool: string; input: string; text: string }>
30 files: FileChange[]
31 checks: Check[] // the newest of each name
32 lastEdit: number // when the newest file change landed; a check older than it is stale
33 context: { percent?: number; window?: number; tokens?: number }
34 // the window's tokens as each of your turns ended, since the last summary, newest last; and where auto-compact starts,
35 // absent when it is off or not yet read. Together they predict the turns left before auto-compact (turnsLeft)
36 turnTokens: number[]
37 compactAt?: number
38 est: Est
39 sources: Source[]
40 summary?: string // the newest compaction's summary text
41 compactions: number
42 cost?: number // USD
43 agents: Agent[] // newest first
44 onPlan: boolean // rateLimits reported: a subscription, where a dollar figure means nothing to the person
45}
46export type View = 'overview' | 'context' | 'changes' | 'diff' | 'recap' | 'details'
47export type Rerun = { busy: boolean; note?: string; at?: number } // at: when the note was written; a check run after it retires it
48export type Recap = { status: 'idle' | 'drafting' | 'ready' | 'applying' | 'applied' | 'failed'; text: string; note?: string }
49
50declare module 'claude-code' {
51 interface PluginState {
52 'session-pane': { model: SessionModel; view: View; help: boolean; diff: string; recap: Recap; shown: boolean; rerun: Rerun }
53 }
54}
55