Shows this session's state in the sidebar: context fill, token totals, cost, model and effort, the Claude Code version, the other sessions of this machine, and…

How full the context is, what the session cost, which effort the last request went out with and what state the branch is in are spread over /context, /cost, /model and a git call, and none of them stays on screen. This mod keeps this session's state in the sidebar: context fill, token totals, cost, model and effort, the Claude Code version, the other sessions of this machine, and the git branch and status.
A section at the top of the sidebar (order: 5), measured on Claude Code 2.1.282:
session-watch: session ctx 8% · 81k / 1.0M · CR 74k · CW 7k · CH 91% tokens T 81k · I 2 · O 8 · TH 5 · CR 74k · CW 7k · CH 91% cost $0.07 model opus-5-5[1m] · effort medium Claude Code 2.1.282 sessions: 2 others · 1 busy · 1 idle cors-fix main · 1 untracked · no upstream
ctx: the input tokens of the last reply over the model's context window, and their share. Green under 50%, yellow from 50% to 80%, red above 80%. The engine reports all three ($.session.usage().context); the mod computes none of them. After it come the cache read and cache write tokens of the main loop's last model request, the one that holds the window now, and that request's cache hit (CH, the same measure as on the tokens line). The request's uncached input (in a cached session the few tokens past the cache, often 1 or 2) and its output are left to the tokens line. A subagent's request has a window of its own and does not change the line. Only the percentages are coloured. Before the first reply the line reads ctx: no reply yet.tokens: the session's totals: T all, I input, O output, TH the thinking tokens, a part of O, CR cache reads, CW cache writes, and CH the cache hit: the share of the input tokens read from the cache, CR / (I + CR + CW), rounded down so a session with any cold write never reads 100%. Only the percentage is coloured: green from 90%, yellow from 70% to 90%, red under 70%. It is left out before any input. A session with no totals kept reads them once from its transcripts (the main loop's and each subagent's), counting each model response once, and every turn after that adds its own, a subagent's too. A request no transcript records counts as it returns: a plugin's own model call ($.model.fork, $.model.complete, such as the fork memory-save runs at each turn's end) and a compaction's summary. Measured on 2.1.282: a fork of 16 input tokens and a completion of 14 raised I from 2 to 32, and a fork fires no turn.complete, so it counts once. The totals are kept in $.store per session, so a reloaded module goes on from them. While the transcripts are read the line reads tokens: reading the transcripts. Measured on 2.1.282 in a resumed session: the totals equalled the /cost row of the session's model, 6 input, 19 output, 222.3k cache read, 21.5k cache write. No hook's usage carries the thinking tokens (measured on 2.1.283: a request's hook usage held input, output and cache counts, and its transcript line held output_tokens_details.thinking_tokens: 8 beside them). So TH is read from the transcripts: once in full, then only what each transcript gained since the last read. Totals kept before TH existed keep their counts, because those hold the plugin model calls no transcript records, and read the thinking alone from the transcripts once.cost: the session's cost in US dollars, as /cost totals it.model: the main loop's model, and the effort of the main loop's last model request: low to max, a budget, no effort setting for a model without one, or effort: not read yet when nothing names one. Before the first request, the line shows the effort the transcript's last response recorded (a resumed session), else CLAUDE_CODE_EFFORT_LEVEL, else the settings' effortLevel. When a hook beneath session-watch sent that request at another effort than the session's setting, such as effort-auto, the line shows both, the setting faint: effort low (session medium). The model's name is coloured by family, the dearest the warmest: opus red, fable yellow, sonnet green, haiku faint. The effort level is coloured by how hard it asks: low faint, medium green, high yellow, xhigh and max red. Colouring one word needs sidebar 0.11.0 or later; an older sidebar draws the line in one colour.Claude Code: the engine's version.sessions: the other live Claude Code sessions of this machine, which spend the same account's usage limits: how many, the busy ones counted in yellow, the idle ones counted, then each busy one by name on its own indented line (at most three, the rest as +N more). Read from Claude Code's registry, <config dir>/sessions/<pid>.json; a file whose pid no longer runs (a crashed session leaves it) is left out, checked with one ps. No other session writes no line.detached at <sha>), the staged, modified, untracked and conflicted files, and the commits ahead of and behind the upstream (↑1 ↓0, or no upstream). Only the state is coloured: clean green, each count of changed files yellow and a conflict red, a commit count yellow above zero and faint at zero, and no upstream faint. Outside a repository it reads git: this folder is not a git repository, also where git itself speaks another language, because git runs in the C locale.A status line in place of the section while the sidebar is closed or not installed:
session-watch: ctx 56% · 557k / 1.0M · $0.11 · CR 557k · CW 502 · CH 99% · main*
It holds the context fill, the cost, the cache split of the main loop's last request as the section's ctx line has it, and the branch; the split is the last response's the transcript recorded until the session's next request, and the branch is left out outside a git repository. A * after the branch marks a tree with changes. The status line is cleared while the sidebar holds the section.
git, so a commit, checkout or pull shows at once.1 untracked into 1 staged within one tick.$.model.fork, $.model.complete) or a compaction, so memory-save's fork at a turn's end shows at once. The redraw runs from a timer, so the call's caller does not wait for its git run./session-watch, which also prints the section's lines.Each reading runs git status --porcelain=v2 --branch once in the directory the session started in. A reading that fails is logged once as cannot read the session: <error>.
/session-watch the section's lines, read now
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install session-watch@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
/sidebar. Without it the mod writes the status line.git. Without it the git line reads the error.Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, turn.step, turn.complete, model.fork, model.complete, session.compact, tool.call{tool=Bash}, command.run{command=session-watch} ❯ ./register.ts calls: $.clock.after (via countCall, startTotals), $.clock.every, $.command.register, $.env.get (via configDirOf, readTail), $.fs.exists (via readOthers, readTail, transcriptsOf), $.fs.list (via readOthers, transcriptsOf), $.fs.read (via readOthers), $.fs.stat (via tailWithResponse, transcriptsOf), $.process.run (via livePids, readGit, tailWithResponse), $.process.spawn (via followOne, readTotals), $.session.id, $.session.model (via readNow), $.session.root, $.session.usage (via readNow), $.session.version, $.settings.read (via readTail), $.sidebar.set (via show), $.store.delete (via startTotals), $.store.get (via keepTotals, startTotals), $.store.set (via keepTotals), $.ui.log (via refresh, seedFromTail, seedTotals, startTotals, tryFollow), $.ui.status (via show) ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: CLAUDE_CODE_EFFORT_LEVEL, CLAUDE_CONFIG_DIR, HOME
Reach L2, it runs git, ps, head and tail.
haiku row in /cost) reach no hook and are not counted; measured on 2.1.282, 888 of 902 haiku input tokens in a probe session. $.model.classify reports no usage and is not counted either. cost counts every request.turn.complete adds the whole turn.next.trace, the deepest link's input). Measured on 2.1.283 with the sidebar open: a session set to medium whose turn effort-auto sent at low read effort low (session medium), and the next turn, not rated, read effort medium. When the main loop's turn ends, the line goes back to the setting alone, because a changed effort lasts one turn at most. While a turn's first request runs, the line shows the setting alone, because what that request went out with is known only once it has run. A subagent's own setting is not shown.git status reads the directory the session started in; a Bash cd into another repository does not move it.git refreshes the git line only at the turn's end or the timer.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 371 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 addSplit, endFile, failedGit, otherSessionOf, thinkingOf, usageOf, valueOf, NO_SPLIT, parseStatus, scanUsage, sentEffort, settingsEffort, lastEffortOf, lastSplitOf, sidebarLines, statusText, storedSplits, sumSplits, transcriptDir, usageScannerOf, usageTotal, withSplit,
4 type Effort, type GitState, type OtherSession, type Reading, type Split, type Usage, type UsageScanner,
5} from './watch.ts'
6
7/** The totals of each session, counted from its transcripts on. */
8const TOTALS_KEY = 'totals'
9/** Where 0.1.0 kept totals counted from the module's first load; they are read again from the transcripts. */
10const OLD_KEY = 'tokens'
11const TICK_MS = 10_000
12const GIT_MS = 10_000
13/** A Bash command that may move the branch or the working tree. */
14const GIT_COMMAND = /\bgit\b/
15
16/**
17 * What the hooks share: the repository the session started in, the session id, the token totals, the main
18 * loop's last effort setting and the effort that request went out with, the engine's version, the last git state, and the last refresh error.
19 */
20type State = { root: string; sid: string; split: Split; seeding: boolean; effort: Effort; sent?: Effort; last?: Split; tailError?: string; version: string; git?: GitState; lastError?: string; follow: Map<string, Follow> }
21
22/** A transcript and its size when the reading began. */
23type Transcript = { path: string; size: number }
24
25/**
26 * A transcript followed past the reading: the bytes read so far, and its responses by message id, so a
27 * response whose lines straddle two reads counts once. No hook's usage names the thinking tokens, so they
28 * are read from what the transcripts gained since.
29 */
30type Follow = { offset: number; scan: UsageScanner }
31
32const utf8 = new TextEncoder()
33
34/** Starts following each transcript at the size it has now; one that appears later is read whole. */
35function startFollow(state: State, files: readonly Transcript[]): void {
36 state.follow = new Map(files.map(f => [f.path, { offset: f.size, scan: usageScannerOf() }]))
37}
38
39/** Reads what one transcript gained since the last read; a line still being written waits for the rest. */
40async function followOne($: EngineInterface, state: State, f: Transcript): Promise<void> {
41 const cur = state.follow.get(f.path) ?? { offset: 0, scan: usageScannerOf() }
42 state.follow.set(f.path, cur)
43 if (f.size <= cur.offset) return
44 for await (const chunk of $.process.spawn({ argv: ['tail', '-c', `+${cur.offset + 1}`, f.path] })) {
45 if (chunk.stream !== 'stdout') throw new Error(`${f.path}: ${chunk.text.trim()}`)
46 scanUsage(cur.scan, chunk.text)
47 cur.offset += utf8.encode(chunk.text).length
48 }
49}
50
51const followedThinking = (state: State): number => [...state.follow.values()].reduce((a, f) => a + thinkingOf(f.scan), 0)
52
53/**
54 * Adds the thinking tokens the transcripts gained. `main` alone reads the main loop's transcript, as after
55 * each of its requests; the turn's end reads every transcript, the subagents' too.
56 */
57async function followThinking($: EngineInterface, state: State, main: boolean): Promise<void> {
58 const before = followedThinking(state)
59 const files = await transcriptsOf($, state)
60 const mainPath = files.find(f => f.path.endsWith(`/${state.sid}.jsonl`))?.path
61 for (const f of files) if (!main || f.path === mainPath) await followOne($, state, f)
62 state.split = { ...state.split, thinking: state.split.thinking + followedThinking(state) - before }
63}
64
65/** Follows the transcripts, and says a failed read once instead of failing the hook. */
66async function tryFollow($: EngineInterface, state: State, main: boolean): Promise<void> {
67 try {
68 await followThinking($, state, main)
69 } catch (err) {
70 const text = `the thinking tokens were not read: ${err instanceof Error ? err.message : String(err)}`
71 if (text !== state.lastError) $.ui.log(text)
72 state.lastError = text
73 }
74}
75
76async function configDirOf($: EngineInterface): Promise<string> {
77 return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
78}
79
80/** The session's transcripts and their sizes now: the main loop's and each subagent's. */
81async function transcriptsOf($: EngineInterface, state: State): Promise<Transcript[]> {
82 const dir = transcriptDir(await configDirOf($), state.root)
83 const main = `${dir}/${state.sid}.jsonl`
84 const subs = `${dir}/${state.sid}/subagents`
85 const paths = (await $.fs.exists(main)) ? [main] : []
86 if (await $.fs.exists(subs)) {
87 for (const f of await $.fs.list(subs)) if (f.kind === 'file' && f.name.endsWith('.jsonl')) paths.push(`${subs}/${f.name}`)
88 }
89 return Promise.all(paths.map(async path => ({ path, size: (await $.fs.stat(path)).size })))
90}
91
92/**
93 * The transcripts' totals, streamed up to the sizes they had when the reading began, because a
94 * transcript can pass the file read limit, and what was written after it is counted turn by turn.
95 */
96async function readTotals($: EngineInterface, files: readonly Transcript[]): Promise<Split> {
97 const s = usageScannerOf()
98 for (const f of files) {
99 let err = ''
100 for await (const chunk of $.process.spawn({ argv: ['head', '-c', String(f.size), f.path] })) {
101 if (chunk.stream === 'stdout') scanUsage(s, chunk.text)
102 else err += chunk.text
103 }
104 if (err !== '') throw new Error(`${f.path}: ${err.trim()}`)
105 endFile(s)
106 }
107 return usageTotal(s)
108}
109
110async function keepTotals($: EngineInterface, state: State): Promise<void> {
111 await $.store.set(TOTALS_KEY, withSplit(storedSplits(await $.store.get(TOTALS_KEY)), state.sid, state.split))
112}
113
114/** Adds the transcripts' totals under the turns counted while they were read, then keeps and draws them. */
115async function seedTotals($: EngineInterface, state: State, files: readonly Transcript[], thinkingOnly: boolean): Promise<void> {
116 try {
117 const read = await readTotals($, files)
118 // Totals kept before thinking was counted keep their counts and take the thinking alone.
119 state.split = thinkingOnly ? { ...state.split, thinking: state.split.thinking + read.thinking } : sumSplits(read, state.split)
120 } catch (err) {
121 $.ui.log(`the token totals count from the module's load, because the transcripts were not read: ${err instanceof Error ? err.message : String(err)}`)
122 }
123 state.seeding = false
124 await keepTotals($, state)
125 await refresh($, state)
126}
127
128/**
129 * The totals this session kept, or a reading of its transcripts started for a session with none. The
130 * sizes are read now, so a turn that ends after this point is counted once, by `turn.complete`.
131 */
132async function startTotals($: EngineInterface, state: State): Promise<void> {
133 const kept = storedSplits(await $.store.get(TOTALS_KEY))[state.sid]
134 await $.store.delete(OLD_KEY)
135 const thinkingOnly = kept?.noThinking === true
136 state.split = kept === undefined ? NO_SPLIT : { input: kept.input, output: kept.output, cacheRead: kept.cacheRead, cacheWrite: kept.cacheWrite, thinking: kept.thinking }
137 try {
138 const files = await transcriptsOf($, state)
139 startFollow(state, files)
140 if (kept !== undefined && !thinkingOnly) return
141 state.seeding = true
142 // The reading outlives the session.start dispatch, so it runs from a timer.
143 $.clock.after(0, () => void seedTotals($, state, files, thinkingOnly))
144 } catch (err) {
145 $.ui.log(`the token totals count from the module's load, because the transcripts were not found: ${err instanceof Error ? err.message : String(err)}`)
146 }
147}
148
149/**
150 * How much of the transcript's end is read for the last recorded effort and split, each size tried after
151 * the one before found no response. A resumed session writes large attachment lines after the last
152 * response (measured: 294 KiB), and `$.process.run` cuts its output at 4 MiB.
153 */
154const TAIL_BYTES = [262_144, 1_048_576, 4_000_000]
155
156/** What the main transcript's tail says before the session's next request: the last effort and the last split. */
157type Tail = { effort?: string | number; last?: Split }
158
159/**
160 * The effort and the context split to show before the session's first request: what the transcript's last
161 * response recorded (a resumed or reloaded session), and for the effort, else what the environment or the
162 * settings name.
163 */
164async function readTail($: EngineInterface, state: State): Promise<Tail> {
165 const main = `${transcriptDir(await configDirOf($), state.root)}/${state.sid}.jsonl`
166 const tail = (await $.fs.exists(main)) ? await tailWithResponse($, main) : ''
167 const recorded = lastEffortOf(tail)
168 const env = await $.env.get('CLAUDE_CODE_EFFORT_LEVEL')
169 const effort = recorded ?? (env !== undefined && env !== '' ? env : settingsEffort(await $.settings.read()))
170 return { effort, last: lastSplitOf(tail) }
171}
172
173/** The shortest end of the transcript that holds a response, or its last 4 MB when none does. */
174async function tailWithResponse($: EngineInterface, path: string): Promise<string> {
175 const size = (await $.fs.stat(path)).size
176 let tail = ''
177 for (const bytes of TAIL_BYTES) {
178 const r = await $.process.run(['tail', '-c', String(bytes), path], { timeoutMs: GIT_MS })
179 if (r.exitCode !== 0) throw new Error(`${path}: ${r.stderr.trim()}`)
180 tail = r.stdout
181 if (lastSplitOf(tail) !== undefined || bytes >= size) break
182 }
183 return tail
184}
185
186/** Seeds the effort and the context split unless a request has already set them, and says a failed read once. */
187async function seedFromTail($: EngineInterface, state: State): Promise<void> {
188 try {
189 const found = await readTail($, state)
190 if (state.effort === undefined && found.effort !== undefined) state.effort = found.effort
191 if (state.last === undefined && found.last !== undefined) state.last = found.last
192 } catch (err) {
193 const text = `the transcript's last response was not read before the first request: ${err instanceof Error ? err.message : String(err)}`
194 if (text !== state.tailError) $.ui.log(text)
195 state.tailError = text
196 }
197}
198
199/**
200 * The git state of the session's directory, from one `git status` run. Git runs in the C locale, because
201 * `failedGit` reads its English message and a localized git words it otherwise.
202 */
203async function readGit($: EngineInterface, state: State): Promise<GitState> {
204 try {
205 const r = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd: state.root, timeoutMs: GIT_MS, env: { LC_ALL: 'C' } })
206 return r.exitCode === 0 ? parseStatus(r.stdout) : failedGit(r.stderr)
207 } catch (err) {
208 return { kind: 'error', message: err instanceof Error ? err.message : String(err) }
209 }
210}
211
212/** The pids of those that still run, from one `ps`: a session that crashed leaves its registry file behind. */
213async function livePids($: EngineInterface, pids: readonly number[]): Promise<Set<number>> {
214 if (pids.length === 0) return new Set()
215 const r = await $.process.run(['ps', '-o', 'pid=', '-p', pids.join(',')], { timeoutMs: GIT_MS })
216 return new Set(r.stdout.split('\n').map(l => Number(l.trim())).filter(n => n > 0))
217}
218
219/** The other live sessions of this machine, from Claude Code's session registry. */
220async function readOthers($: EngineInterface, state: State): Promise<OtherSession[]> {
221 const dir = `${await configDirOf($)}/sessions`
222 if (!(await $.fs.exists(dir))) return []
223 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && /^\d+\.json$/.test(f.name))
224 const found: OtherSession[] = []
225 for (const f of files) {
226 const s = otherSessionOf(String(await $.fs.read(`${dir}/${f.name}`)), state.sid)
227 if (s !== undefined) found.push(s)
228 }
229 const live = await livePids($, found.map(s => s.pid))
230 return found.filter(s => live.has(s.pid))
231}
232
233/** Everything the section shows, read now. */
234async function readNow($: EngineInterface, state: State): Promise<Reading> {
235 const [usage, model, git, others] = await Promise.all([$.session.usage(), $.session.model(), readGit($, state), readOthers($, state)])
236 state.git = git
237 return { context: usage.context, costUsd: usage.cost?.usd, split: state.split, last: state.last, seeding: state.seeding, model, effort: state.effort, sent: state.sent, version: state.version, git, others }
238}
239
240/** Writes the reading into the sidebar, and into the status line while the sidebar does not take it. */
241async function show($: EngineInterface, reading: Reading): Promise<void> {
242 let shown = false
243 try {
244 shown = await $.sidebar.set({ consumer: 'session-watch', key: 'session', title: 'session', lines: sidebarLines(reading), until: 'session', order: 5 })
245 } catch {
246 // The sidebar mod is not installed.
247 }
248 $.ui.status(shown ? undefined : statusText(reading))
249}
250
251/** Reads and shows; a failed read is logged once instead of thrown, because a timer has no hook to fail. */
252async function refresh($: EngineInterface, state: State): Promise<void> {
253 // A resumed session's transcript is written after session.start, so its last split is read again until found.
254 if (state.last === undefined) await seedFromTail($, state)
255 try {
256 await show($, await readNow($, state))
257 state.lastError = undefined
258 } catch (err) {
259 const text = err instanceof Error ? err.message : String(err)
260 if (text !== state.lastError) $.ui.log(`cannot read the session: ${text}`)
261 state.lastError = text
262 }
263}
264
265/**
266 * Adds one turn's tokens and keeps the totals in the store, so a reloaded module goes on from them.
267 * While the transcripts are read the totals are partial, so they are kept once the reading ends.
268 */
269async function countTurn($: EngineInterface, state: State, usage: Usage | undefined): Promise<void> {
270 if (usage === undefined) return
271 state.split = addSplit(state.split, usage)
272 if (!state.seeding) await keepTotals($, state)
273}
274
275/**
276 * Counts a model call or a compaction and redraws from a timer, so the caller (memory-save's fork at a
277 * turn's end) does not wait for the git run of the redraw.
278 */
279async function countCall($: EngineInterface, state: State, usage: Usage | undefined): Promise<void> {
280 if (usage === undefined) return
281 await countTurn($, state, usage)
282 $.clock.after(0, () => void refresh($, state))
283}
284
285/** The `/session-watch` answer: the reading as the section's lines. */
286async function commandText($: EngineInterface, state: State): Promise<string> {
287 const reading = await readNow($, state)
288 await show($, reading)
289 return sidebarLines(reading).map(l => l.text).join('\n')
290}
291
292export const register: Register = on => {
293 const state: State = { root: '', sid: '', split: NO_SPLIT, seeding: false, effort: undefined, version: '', follow: new Map() }
294
295 on('session.start', async ($, e, next) => {
296 const r = await next(e)
297 state.root = await $.session.root()
298 state.sid = await $.session.id()
299 state.version = (await $.session.version()).version
300 await startTotals($, state)
301 await $.command.register({ name: 'session-watch', description: 'This session\'s context, tokens, cost, model, version and git state (session-watch)', immediate: true })
302 // A -p run draws nothing, so only an interactive session refreshes on a timer.
303 if (e.isInteractive) $.clock.every(TICK_MS, () => void refresh($, state))
304 await refresh($, state)
305 return r
306 })
307
308 // The main loop's request says how hard it asks the model to think, and its usage is the window's own; a
309 // subagent's request has its own setting and its own window.
310 on('turn.step', async function* ($, e, next) {
311 // The setting is read before the request; what it went out with is known only once it has run, so a
312 // turn's first request shows the setting alone, not the last turn's.
313 if (e.agentId === undefined) {
314 state.effort = e.effort ?? null
315 if (e.index === 0) state.sent = undefined
316 }
317 const r = yield* next(e)
318 // A hook beneath this one may have sent the request at another effort; the chain's trace says which.
319 if (e.agentId === undefined) state.sent = sentEffort(next.trace, e.effort)
320 // The main loop's last request holds the context window, so its split is the window's own.
321 if (e.agentId === undefined && r.usage) {
322 state.last = addSplit(NO_SPLIT, r.usage)
323 await tryFollow($, state, true)
324 }
325 return r
326 })
327
328 // Every loop's turn counts toward the totals, as /cost counts them; the main loop's end redraws.
329 on('turn.complete', async ($, e, next) => {
330 const r = await next(e)
331 await countTurn($, state, e.usage)
332 if (e.agentId !== undefined) return r
333 // A changed effort lasts one turn at most (effort-auto), so the line goes back to the session's setting.
334 state.sent = undefined
335 await tryFollow($, state, false)
336 if (!state.seeding) await keepTotals($, state)
337 await refresh($, state)
338 return r
339 })
340
341 // A plugin's own model calls and a compaction's summary are requests no transcript records, so they
342 // count here, as /cost counts them. The engine's own side calls reach no hook and stay out.
343 // A model call is an op event: its hooks resolve to `{ value }` or `{ deny }`, not the bare result.
344 on('model.fork', async ($, e, next) => {
345 const r = await next(e)
346 await countCall($, state, usageOf(valueOf(r)))
347 return r
348 })
349
350 on('model.complete', async ($, e, next) => {
351 const r = await next(e)
352 await countCall($, state, usageOf(valueOf(r)))
353 return r
354 })
355
356 on('session.compact', async ($, e, next) => {
357 const r = await next(e)
358 await countCall($, state, usageOf(r))
359 return r
360 })
361
362 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
363 const r = await next(e)
364 if (GIT_COMMAND.test(e.command)) await refresh($, state)
365 return r
366 })
367
368 // The engine prints the plugin name in front of command text, so the text does not repeat it.
369 on('command.run', { command: 'session-watch' }, async $ => ({ text: await commandText($, state) }))
370}
371hooks/watch.ts 491 lines1/**
2 * What session-watch reads and how it draws it: the token totals it adds up, the git status it parses,
3 * and the sidebar and status lines. Nothing here reads the engine or the clock.
4 */
5
6/** The tokens of every turn so far, by kind. `thinking` is a part of `output`, read from the transcripts alone. */
7export type Split = { input: number; output: number; cacheRead: number; cacheWrite: number; thinking: number; noThinking?: true }
8
9export const NO_SPLIT: Split = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }
10
11/**
12 * The token counts one turn carries, as `turn.complete` names them. A transcript line also carries
13 * `output_tokens_details.thinking_tokens`, which no hook's usage holds (measured on 2.1.283).
14 */
15export type Usage = {
16 input_tokens?: number
17 output_tokens?: number
18 cache_read_input_tokens?: number
19 cache_creation_input_tokens?: number
20 output_tokens_details?: { thinking_tokens?: unknown } | null
21}
22
23const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
24
25/** A count read from a usage record, which a transcript line makes untrusted; another value reads as 0. */
26const countOf = (v: unknown): number => (isCount(v) ? v : 0)
27
28/** A split with one more turn's tokens added. */
29export function addSplit(split: Split, usage: Usage | undefined): Split {
30 if (usage === undefined) return split
31 return {
32 input: split.input + countOf(usage.input_tokens),
33 output: split.output + countOf(usage.output_tokens),
34 cacheRead: split.cacheRead + countOf(usage.cache_read_input_tokens),
35 cacheWrite: split.cacheWrite + countOf(usage.cache_creation_input_tokens),
36 thinking: split.thinking + thinkingIn(usage),
37 }
38}
39
40/** The thinking tokens a usage record names; 0 where it names none, as every hook's usage does. */
41export function thinkingIn(usage: Usage): number {
42 return countOf(usage.output_tokens_details?.thinking_tokens)
43}
44
45/**
46 * The usage a model call's or a compaction's result carries, or undefined where it made no request (a
47 * fork with nothing to fork, a skipped compaction, a compaction a hook answered).
48 */
49export function usageOf(result: unknown): Usage | undefined {
50 const usage = typeof result === 'object' && result !== null ? (result as { usage?: unknown }).usage : undefined
51 return typeof usage === 'object' && usage !== null ? (usage as Usage) : undefined
52}
53
54/** The value an op event's result carries (`{ value }`), or undefined for a `{ deny }`. */
55export function valueOf(result: unknown): unknown {
56 return typeof result === 'object' && result !== null ? (result as { value?: unknown }).value : undefined
57}
58
59/** Two splits added. */
60export function sumSplits(a: Split, b: Split): Split {
61 return { input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite, thinking: a.thinking + b.thinking }
62}
63
64const totalOf = (s: Split): number => s.input + s.output + s.cacheRead + s.cacheWrite
65
66/** A token count as `830`, `245k`, `1.2M` or `3.1B`. */
67export function fmtTok(n: number): string {
68 if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
69 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
70 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
71}
72
73/**
74 * The directory of a session's transcripts: under `projects/`, named after the session's start directory
75 * with every character but a letter or a digit turned into `-` (measured on 2.1.280). It holds
76 * `<session id>.jsonl` and, for a session that ran subagents, `<session id>/subagents/*.jsonl`.
77 */
78export function transcriptDir(configDir: string, root: string): string {
79 return `${configDir}/projects/${root.replace(/[^A-Za-z0-9]/g, '-')}`
80}
81
82/**
83 * A transcript read piece by piece: the usage of each model response, by message id. The engine writes
84 * one line per content block of a response, each with the response's usage, so a response counts once;
85 * its last line wins, because a streamed response's earlier lines can carry a smaller output count.
86 */
87export type UsageScanner = { rest: string; byId: Map<string, Usage>; noId: Split }
88
89export const usageScannerOf = (): UsageScanner => ({ rest: '', byId: new Map(), noId: NO_SPLIT })
90
91type UsageRow = { type?: unknown; message?: { id?: unknown; usage?: unknown } }
92
93/** The row a line holds, or undefined for a line that is not JSON (a line cut at the snapshot's end). */
94function rowOf(line: string): UsageRow | undefined {
95 try {
96 return JSON.parse(line) as UsageRow
97 } catch {
98 return undefined
99 }
100}
101
102/** A model response's id and usage, from a transcript line, or undefined for a line of another kind. */
103function responseOf(line: string): { id: unknown; usage: Usage } | undefined {
104 // Parsing every row of a long transcript costs more than it needs: only a response's own row counts.
105 if (!line.includes('"usage"') || !line.includes('"assistant"')) return undefined
106 const row = rowOf(line)
107 const usage = row?.message?.usage
108 if (row?.type !== 'assistant' || typeof usage !== 'object' || usage === null) return undefined
109 return { id: row.message?.id, usage: usage as Usage }
110}
111
112function takeUsageLine(s: UsageScanner, line: string): void {
113 const r = responseOf(line)
114 if (r === undefined) return
115 if (typeof r.id === 'string') s.byId.set(r.id, r.usage)
116 else s.noId = addSplit(s.noId, r.usage)
117}
118
119/** Reads the next piece of a transcript; a line cut between pieces waits for the rest. */
120export function scanUsage(s: UsageScanner, text: string): void {
121 const lines = (s.rest + text).split('\n')
122 s.rest = lines.pop() ?? ''
123 for (const line of lines) takeUsageLine(s, line)
124}
125
126/** Reads what is left after a file's last piece, so the next file starts on a line of its own. */
127export function endFile(s: UsageScanner): void {
128 if (s.rest.trim() !== '') takeUsageLine(s, s.rest)
129 s.rest = ''
130}
131
132/** The totals of every transcript read so far. */
133export function usageTotal(s: UsageScanner): Split {
134 endFile(s)
135 return [...s.byId.values()].reduce(addSplit, s.noId)
136}
137
138/** The thinking tokens of every response a scanner has read, each response once. */
139export function thinkingOf(s: UsageScanner): number {
140 return [...s.byId.values()].reduce((a, u) => a + thinkingIn(u), s.noId.thinking)
141}
142
143/** A stored split's four counts; thinking is read on its own, because an older version kept none. */
144function hasCounts(v: unknown): v is Omit<Split, 'thinking'> & { thinking?: unknown; noThinking?: unknown } {
145 const s = v as Partial<Split> | null
146 return typeof s === 'object' && s !== null && isCount(s.input) && isCount(s.output) && isCount(s.cacheRead) && isCount(s.cacheWrite)
147}
148
149/**
150 * A stored split as the totals use it. One kept before thinking was counted has no `thinking`: its four
151 * counts stay, because they hold the plugin model calls and compactions no transcript records, and it is
152 * marked `noThinking`, so the session reads the thinking alone from its transcripts.
153 */
154function splitOf(s: Omit<Split, 'thinking'> & { thinking?: unknown; noThinking?: unknown }): Split {
155 const counts = { input: s.input, output: s.output, cacheRead: s.cacheRead, cacheWrite: s.cacheWrite }
156 return isCount(s.thinking) && s.noThinking !== true ? { ...counts, thinking: s.thinking } : { ...counts, thinking: 0, noThinking: true }
157}
158
159/** How many sessions' totals the store keeps, so the file does not grow with every session. */
160const KEPT_SESSIONS = 20
161
162/** The totals the store keeps, by session id; a value of another shape reads as none. */
163export function storedSplits(value: unknown): Record<string, Split> {
164 if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
165 return Object.fromEntries(Object.entries(value).flatMap(([id, s]) => (hasCounts(s) ? [[id, splitOf(s)]] : [])))
166}
167
168/** The stored totals with this session's written last, the oldest dropped past the kept number. */
169export function withSplit(stored: Record<string, Split>, sid: string, split: Split): Record<string, Split> {
170 const others = Object.entries(stored).filter(([id]) => id !== sid)
171 return Object.fromEntries([...others, [sid, split] as const].slice(-KEPT_SESSIONS))
172}
173
174/** The working tree as `git status --porcelain=v2 --branch` reports it. */
175export type Tree = {
176 kind: 'repo'
177 /** The branch, or `detached at <sha>`. */
178 head: string
179 /** Commits ahead of and behind the upstream, or undefined when the branch has none. */
180 ab?: { ahead: number; behind: number }
181 staged: number
182 modified: number
183 untracked: number
184 conflicted: number
185}
186
187/** The git state: a tree, no repository, or why git did not answer. */
188export type GitState = Tree | { kind: 'none' } | { kind: 'error'; message: string }
189
190const EMPTY_TREE: Tree = { kind: 'repo', head: '', staged: 0, modified: 0, untracked: 0, conflicted: 0 }
191
192/** One header line (`# branch.head main`) read into the tree. */
193function readHeader(tree: Tree, line: string, oid: string): Tree {
194 const [, key = '', ...rest] = line.split(' ')
195 const value = rest.join(' ')
196 if (key === 'branch.head') return { ...tree, head: value === '(detached)' ? `detached at ${oid.slice(0, 7)}` : value }
197 const ab = /^\+(\d+) -(\d+)$/.exec(value)
198 return key === 'branch.ab' && ab !== null ? { ...tree, ab: { ahead: Number(ab[1]), behind: Number(ab[2]) } } : tree
199}
200
201/** One entry line (`1 .M ...`, `? path`) counted into the tree. */
202function readEntry(tree: Tree, line: string): Tree {
203 if (line.startsWith('? ')) return { ...tree, untracked: tree.untracked + 1 }
204 if (line.startsWith('u ')) return { ...tree, conflicted: tree.conflicted + 1 }
205 if (!line.startsWith('1 ') && !line.startsWith('2 ')) return tree
206 const [x = '.', y = '.'] = line.slice(2, 4)
207 return { ...tree, staged: tree.staged + (x === '.' ? 0 : 1), modified: tree.modified + (y === '.' ? 0 : 1) }
208}
209
210/** The tree `git status --porcelain=v2 --branch` printed. */
211export function parseStatus(stdout: string): Tree {
212 const lines = stdout.split('\n').filter(l => l !== '')
213 const oid = lines.find(l => l.startsWith('# branch.oid '))?.slice('# branch.oid '.length) ?? ''
214 return lines.reduce((tree, line) => (line.startsWith('# ') ? readHeader(tree, line, oid) : readEntry(tree, line)), EMPTY_TREE)
215}
216
217/** The git state of a git run that failed: no repository here, or git's own first line. */
218export function failedGit(stderr: string): GitState {
219 if (/not a git repository/i.test(stderr)) return { kind: 'none' }
220 return { kind: 'error', message: stderr.split('\n').find(l => l.trim() !== '')?.trim() ?? 'git failed' }
221}
222
223const isDirty = (t: Tree): boolean => t.staged + t.modified + t.untracked + t.conflicted > 0
224
225/** The effort setting of the main loop's last request: a level, a budget, none for a model without one, or not read yet. */
226export type Effort = string | number | null | undefined
227
228/** A level or a budget as settings or a transcript line spell it, or undefined for anything else. */
229function effortValue(v: unknown): string | number | undefined {
230 return (typeof v === 'string' && v !== '') || typeof v === 'number' ? v : undefined
231}
232
233/** The last assistant line of a transcript's tail that `pick` finds a value on; a line the tail cut in two is skipped. */
234function lastAssistant<T>(tail: string, pick: (row: { effort?: unknown; message?: { usage?: unknown } }) => T | undefined): T | undefined {
235 const lines = tail.split('\n')
236 for (let i = lines.length - 1; i >= 0; i--) {
237 const line = lines[i] ?? ''
238 if (!line.includes('"assistant"')) continue
239 try {
240 const d = JSON.parse(line) as { type?: unknown; effort?: unknown; message?: { usage?: unknown } }
241 const found = d.type === 'assistant' ? pick(d) : undefined
242 if (found !== undefined) return found
243 } catch {
244 // A line the tail cut in two is not a record.
245 }
246 }
247 return undefined
248}
249
250/** The effort the last response of a transcript's tail recorded, the engine's `effort` field on each response. */
251export function lastEffortOf(tail: string): string | number | undefined {
252 return lastAssistant(tail, d => effortValue(d.effort))
253}
254
255/**
256 * The split of the last response a transcript's tail recorded: the request that holds the context window,
257 * so the context line has it before the session's next request.
258 */
259export function lastSplitOf(tail: string): Split | undefined {
260 const usage = lastAssistant(tail, d => usageOf(d.message))
261 return usage === undefined ? undefined : addSplit(NO_SPLIT, usage)
262}
263
264/** The effort settings name: `effortLevel`, as `/effort` writes it; undefined when none is set. */
265export function settingsEffort(settings: Readonly<Record<string, unknown>>): string | number | undefined {
266 return effortValue(settings.effortLevel)
267}
268
269/** A link of the chain beneath a hook, as `next.trace` lists it: its place, and the request it received. */
270export type StepLink = { readonly index: number; readonly received: { readonly effort?: string | number } }
271
272/**
273 * The effort a request went out with: what the deepest link beneath received, the engine's own end, so a
274 * hook beneath this one that changed the effort (effort-auto) is read too; the request's own effort, or none,
275 * when nothing beneath is listed.
276 */
277export function sentEffort(trace: readonly StepLink[], asked: string | number | undefined): Effort {
278 const deepest = trace.reduce<StepLink | undefined>((d, link) => (d === undefined || link.index > d.index ? link : d), undefined)
279 return (deepest === undefined ? asked : deepest.received.effort) ?? null
280}
281
282/** Everything one reading shows. */
283export type Reading = {
284 context: { tokens?: number; window: number; percent?: number }
285 costUsd?: number
286 split: Split
287 /** The split of the last main-loop request, which holds the context window now; unknown before one. */
288 last?: Split
289 /** Whether the session's transcripts are still being read into the totals. */
290 seeding: boolean
291 model: string
292 /** The session's effort setting, as the main loop's last request was asked with it. */
293 effort: Effort
294 /** The effort that request of this turn went out with, which a hook beneath may have changed; unknown before one. */
295 sent?: Effort
296 version: string
297 git?: GitState
298 /** The other live sessions on this machine, which share the account's usage limits. */
299 others?: readonly OtherSession[]
300}
301
302/** How the sidebar colours a line or a part of one. */
303type Tone = 'ok' | 'warn' | 'error' | 'dim'
304export type Part = { text: string; kind?: Tone }
305/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
306export type Line = { text: string; kind?: Tone; parts?: Part[] }
307
308const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
309
310/** A line made of parts, its `text` their texts joined. */
311const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
312
313/** The context's colour: green under 50%, yellow from 50% to 80%, red above 80%. */
314export function contextTone(percent: number): Tone {
315 if (percent < 50) return 'ok'
316 return percent <= 80 ? 'warn' : 'error'
317}
318
319/** The window's fill in tokens: ` · 557k / 1.0M`. */
320function fillText(c: Reading['context']): string {
321 return ` · ${fmtTok(c.tokens ?? 0)} / ${fmtTok(c.window)}`
322}
323
324/**
325 * The context line: the window's fill, then the split of the last main-loop request, the one that holds the
326 * window now, when it is known. Only the percentages are coloured.
327 */
328function contextLine(c: Reading['context'], last: Split | undefined): Line {
329 if (c.percent === undefined) return { text: 'ctx: no reply yet', kind: 'dim' }
330 const fill = [part('ctx ', undefined), part(`${c.percent}%`, contextTone(c.percent)), part(fillText(c), undefined)]
331 return partsLine(last === undefined ? fill : [...fill, ...windowParts(last)])
332}
333
334/**
335 * The window's own split: its cache read and cache write, and the request's cache hit. The uncached input
336 * (the few tokens past the cache) and the output are left to the tokens line.
337 */
338function windowParts(s: Split): Part[] {
339 const kinds = part(` · CR ${fmtTok(s.cacheRead)} · CW ${fmtTok(s.cacheWrite)}`, undefined)
340 const hit = cacheHit(s)
341 return hit === undefined ? [kinds] : [kinds, part(' · CH ', undefined), part(`${hit}%`, cacheHitTone(hit))]
342}
343
344/**
345 * The share of the input tokens read from the cache, in whole percent rounded down, so a session with any
346 * cold write never reads 100%; undefined before any input.
347 */
348export function cacheHit(s: Split): number | undefined {
349 const input = s.input + s.cacheRead + s.cacheWrite
350 return input === 0 ? undefined : Math.floor((100 * s.cacheRead) / input)
351}
352
353/** The cache hit's colour: green from 90%, yellow from 70% to 90%, red under 70%. */
354export function cacheHitTone(percent: number): Tone {
355 if (percent >= 90) return 'ok'
356 return percent >= 70 ? 'warn' : 'error'
357}
358
359/** A split by kind, then its cache hit with only the percentage coloured; no hit before any input. */
360function splitParts(s: Split): Part[] {
361 const kinds = part(` · I ${fmtTok(s.input)} · O ${fmtTok(s.output)} · TH ${fmtTok(s.thinking)} · CR ${fmtTok(s.cacheRead)} · CW ${fmtTok(s.cacheWrite)}`, undefined)
362 const hit = cacheHit(s)
363 return hit === undefined ? [kinds] : [kinds, part(' · CH ', undefined), part(`${hit}%`, cacheHitTone(hit))]
364}
365
366function tokensLine(s: Split, seeding: boolean): Line {
367 if (seeding) return { text: 'tokens: reading the transcripts', kind: 'dim' }
368 const [kinds, ...hit] = splitParts(s)
369 const totals = part(`tokens T ${fmtTok(totalOf(s))}${kinds?.text ?? ''}`, undefined)
370 return hit.length === 0 ? { text: totals.text } : partsLine([totals, ...hit])
371}
372
373function costLine(usd: number | undefined): Line {
374 return usd === undefined ? { text: 'cost: no ledger in this host', kind: 'dim' } : { text: `cost $${usd.toFixed(2)}` }
375}
376
377/** The model as a line names it: without the vendor prefix and the date a full id carries. */
378export function shortModel(model: string): string {
379 return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
380}
381
382/** The model's colour by family, the dearest the warmest: opus red, fable yellow, sonnet green, haiku faint. */
383export function modelTone(model: string): Tone | undefined {
384 const families: [RegExp, Tone][] = [[/opus/i, 'error'], [/fable/i, 'warn'], [/sonnet/i, 'ok'], [/haiku/i, 'dim']]
385 return families.find(([family]) => family.test(model))?.[1]
386}
387
388/** The effort level's colour: low faint, medium green, high yellow, xhigh and max red; a budget has none. */
389export function effortTone(effort: Effort): Tone | undefined {
390 const tones: Record<string, Tone> = { low: 'dim', medium: 'ok', high: 'warn', xhigh: 'error', max: 'error' }
391 return typeof effort === 'string' ? tones[effort] : undefined
392}
393
394/** One effort value: the label and the value, only the value coloured. */
395function effortValueParts(effort: Effort): Part[] {
396 if (effort === undefined) return [part('effort: not read yet', 'dim')]
397 if (effort === null) return [part('no effort setting', 'dim')]
398 if (typeof effort === 'number') return [part(`effort budget ${effort}`, undefined)]
399 return [part('effort ', undefined), part(effort, effortTone(effort))]
400}
401
402/**
403 * The effort part: the effort the request went out with, and the session's setting beside it, faint, when
404 * a hook beneath changed it (`effort low (session medium)`); the setting alone when they agree.
405 */
406function effortParts(effort: Effort, sent: Effort): Part[] {
407 const changed = effort !== undefined && effort !== null && sent !== undefined && sent !== null && sent !== effort
408 return changed ? [...effortValueParts(sent), part(` (session ${effort})`, 'dim')] : effortValueParts(effort)
409}
410
411function modelLine(model: string, effort: Effort, sent: Effort): Line {
412 return partsLine([part('model ', undefined), part(shortModel(model), modelTone(model)), part(' · ', undefined), ...effortParts(effort, sent)])
413}
414
415/** The changes as `2 staged, 3 modified, 1 untracked`, each count yellow and a conflict red, or a green `clean`. */
416function changesParts(t: Tree): Part[] {
417 const counts = [[t.staged, 'staged'], [t.modified, 'modified'], [t.untracked, 'untracked'], [t.conflicted, 'conflicted']] as const
418 const named = counts.filter(([n]) => n > 0).map(([n, what]) => part(`${n} ${what}`, what === 'conflicted' ? 'error' : 'warn'))
419 return named.length === 0 ? [part('clean', 'ok')] : named.flatMap((p, i) => (i === 0 ? [p] : [part(', ', undefined), p]))
420}
421
422/** The commits ahead of and behind the upstream, `↑2 ↓0`, each count yellow above zero and faint at zero. */
423function upstreamParts(ab: Tree['ab']): Part[] {
424 if (ab === undefined) return [part('no upstream', 'dim')]
425 const count = (arrow: string, n: number): Part => part(`${arrow}${n}`, n > 0 ? 'warn' : 'dim')
426 return [count('↑', ab.ahead), part(' ', undefined), count('↓', ab.behind)]
427}
428
429/** The git line: branch, changes and upstream, with the branch and the separators uncoloured. */
430export function gitLine(git: GitState | undefined): Line {
431 if (git === undefined) return { text: 'git: not read yet', kind: 'dim' }
432 if (git.kind === 'none') return { text: 'git: this folder is not a git repository', kind: 'dim' }
433 if (git.kind === 'error') return { text: `git: ${git.message}`, kind: 'dim' }
434 return partsLine([part(`${git.head} · `, undefined), ...changesParts(git), part(' · ', undefined), ...upstreamParts(git.ab)])
435}
436
437/** Another live session, from its registry file under `<config dir>/sessions/<pid>.json`. */
438export type OtherSession = { pid: number; sessionId: string; busy: boolean; place: string }
439
440/**
441 * A registry file of another session, or undefined for this session's own, a file of another shape, or
442 * one without a pid. `place` is the session's name, else its start directory's last part.
443 */
444export function otherSessionOf(text: string, ownId: string): OtherSession | undefined {
445 let j: { pid?: unknown; sessionId?: unknown; status?: unknown; name?: unknown; cwd?: unknown }
446 try {
447 j = JSON.parse(text) as typeof j
448 } catch {
449 return undefined
450 }
451 if (typeof j.pid !== 'number' || typeof j.sessionId !== 'string' || j.sessionId === ownId) return undefined
452 const dir = typeof j.cwd === 'string' ? (j.cwd.split('/').filter(Boolean).at(-1) ?? j.cwd) : '?'
453 return { pid: j.pid, sessionId: j.sessionId, busy: j.status === 'busy', place: typeof j.name === 'string' && j.name !== '' ? j.name : dir }
454}
455
456/** At most this many busy sessions are named on the line; the rest are counted. */
457const MAX_BUSY_NAMED = 3
458
459/**
460 * The other live sessions: one line with how many, the busy count in yellow, because they spend the same
461 * usage limits now, and the idle ones counted; then each busy one by name on its own indented line.
462 * Empty while no other session runs.
463 */
464export function sessionsLines(others: readonly OtherSession[]): Line[] {
465 if (others.length === 0) return []
466 const busy = others.filter(o => o.busy)
467 const busyParts = busy.length === 0 ? [] : [part(' · ', undefined), part(`${busy.length} busy`, 'warn')]
468 const idle = others.length - busy.length
469 const head = partsLine([part(`sessions: ${others.length} other${others.length === 1 ? '' : 's'}`, undefined), ...busyParts, ...(idle === 0 ? [] : [part(` · ${idle} idle`, 'dim')])])
470 const names = busy.slice(0, MAX_BUSY_NAMED).map(o => ({ text: ` ${o.place}`, kind: 'dim' as const }))
471 const rest = busy.length > MAX_BUSY_NAMED ? [{ text: ` +${busy.length - MAX_BUSY_NAMED} more`, kind: 'dim' as const }] : []
472 return [head, ...names, ...rest]
473}
474
475/** The reading as the sidebar section's lines, in the order the person reads them. */
476export function sidebarLines(r: Reading): Line[] {
477 return [contextLine(r.context, r.last), tokensLine(r.split, r.seeding), costLine(r.costUsd), modelLine(r.model, r.effort, r.sent), { text: `Claude Code ${r.version}` }, ...sessionsLines(r.others ?? []), gitLine(r.git)]
478}
479
480/**
481 * The status line while the sidebar is closed: the window's fill, the cost, the last request's cache split
482 * and the branch, `ctx 56% · 557k / 1.0M · $0.11 · CR 557k · CW 502 · CH 99% · main*`.
483 */
484export function statusText(r: Reading): string {
485 const ctx = r.context.percent === undefined ? 'ctx -' : `ctx ${r.context.percent}%${fillText(r.context)}`
486 const cost = r.costUsd === undefined ? '' : ` · $${r.costUsd.toFixed(2)}`
487 const split = r.last === undefined || r.context.percent === undefined ? '' : windowParts(r.last).map(p => p.text).join('')
488 const git = r.git?.kind === 'repo' ? ` · ${r.git.head}${isDirty(r.git) ? '*' : ''}` : ''
489 return `${ctx}${cost}${split}${git}`
490}
491