SLOPSHOPPER

session-pane

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…

panebandspinnercommandtoastmodel
v0.1.0MITupdated 2026-10-24dboeter/cc-plugins/plugins/session-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-pane
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ ── Goal ──────────────────────────────────── │ ┃ fix the failing auth test ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ── Changes ───────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ No files changed yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ── Context ───────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ Nothing measured yet: the first response fil │ ┃ c: What's included ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ──────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ d: Session details h: Show help │ › /session-pane │ ⎿ session-pane: Session pane shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
── Goal ─────────────────────────────────────── draft ── fix the failing auth test ── Changes ───────────────────────────────────────────── No files changed yet ── Context ───────────────────────────────────────────── Nothing measured yet: the first response fills this in. c: What's included ──────────────────────────────────────────────────────── d: Session details h: Show help
README

session-pane

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:

  • Goal: what you're trying to get done, with the constraints you stated. It starts as the first sentence of your opening message and is condensed by a small model only while the pane is open.
  • Changes: the files changed this session (by tools and by shell commands), their diffs, the checks run since and which ones need rerunning. Rerun stale checks runs them again.
  • Right now: shown only when something needs you: a question waiting, a failed check, or subagents still running, each with what it is doing. Session details lists every subagent this session started, with its tool calls and, once it finishes, its tokens.
  • Context: how full the context window is and what it holds, roughly how many turns are left before older messages are summarised (auto-compact; from the median growth of recent turns; shown only once there is an estimate), a row of bars showing how full the window was after each turn since the last summary, and when it's nearly full, a recap you can review before compacting.

Install

/plugin install session-pane --marketplace dboeter/cc-plugins

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

Use

/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:

  • Band above the prompt: between turns, one line when code changed and no check ran, a check failed, a check needs rerunning after an edit, the context is nearly full, or summarising is two turns away or less. When nothing needs you, it doesn't draw. Lines from other mods stay below it.
  • Toasts: one when a check is still failing at the end of a turn, and one when the context crosses 85%. Each alert is announced once while it holds, never mid-turn. It is announced again only if it clears and comes back, for example a check rerun after an edit that still fails, or the context refilling after a summary. When a recap you asked for is ready, a toast says so.
  • Spinner: while Claude works, · 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.

What it does on your machine

  • Goal calls, only 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.
  • Recap: drafted only when you press Prepare a recap, by forking the conversation; applied only when you press Use.
  • Shell edits: after each Bash call it runs 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.
  • Rerun stale checks: runs a stale check's command again, exactly as Claude ran it, only when you press the button and only between turns. It goes through the normal tool call, so your permission rules and dialogs apply. Only a command that is the check alone is offered: not one chained with other commands (npm test && rm -rf dist), not one that sets a variable, and not one stored with a secret masked or cut short.
  • Turns left: after each turn, one local context count ($.session.usage({ breakdown: 'summary' }), no request sent) for the window's size and the auto-compact threshold.
  • Storage: a snapshot of the pane for the 20 most recent sessions, in the plugin's own store, so a restart or resume shows the same pane.

Develop

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-pane
Source 4 files
hooks/session-pane.tsx 338 lines
1import { 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}
338
hooks/model.ts 577 lines
1// 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}
577
hooks/views.tsx 420 lines
1import 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}
420
types/index.d.ts 55 lines
1// 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