A status row under the prompt (mode, model, effort, 5h / 7d / context usage, cache warmth), with the turn's time and live tokens while working

Mods for Claude Code. Each mod has one job and works on its own: none imports, reads or calls another.
| Mod | Its job |
|---|---|
mods/statusdeck | The status row under the prompt: live stats and their colours |
mods/inkstyle | Colours inside Claude's replies and on tool-call rows |
mods/prettier | The layout of the whole chat: prompts, replies, code, tool rows, the spinner |
Copy the mods into ~/.claude/mods, then point Claude Code at them with the CLAUDE_CODE_PLUGIN_DIRS environment variable, prettier first (see Load order). Restart Claude Code after installing.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\mods" | Out-Null
Copy-Item -Recurse -Force .\claude-code-utilities\mods\* "$env:USERPROFILE\.claude\mods\"
[Environment]::SetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS',
"$env:USERPROFILE\.claude\mods\prettier;$env:USERPROFILE\.claude\mods\statusdeck;$env:USERPROFILE\.claude\mods\inkstyle",
'User')
Then quit and reopen your whole terminal app (Windows Terminal, VS Code, any IDE), not just the tab: a new tab inherits the app's old copy of the environment and won't see the variable. Check it before starting claude:
echo $env:CLAUDE_CODE_PLUGIN_DIRS # must start with ...\mods\prettier
Or refresh a single terminal by hand:
$env:CLAUDE_CODE_PLUGIN_DIRS = [Environment]::GetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS','User')
git clone https://github.com/MoizAsif616/claude-code-utilities.git
mkdir -p ~/.claude/mods
cp -r claude-code-utilities/mods/* ~/.claude/mods/
echo 'export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/statusdeck:$HOME/.claude/mods/inkstyle"' >> ~/.bashrc
Open a new shell (or source ~/.bashrc) before starting claude.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
mkdir -p ~/.claude/mods
cp -R claude-code-utilities/mods/* ~/.claude/mods/
echo 'export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/statusdeck:$HOME/.claude/mods/inkstyle"' >> ~/.zshrc
Open a new shell (or source ~/.zshrc) before starting claude.
prettier must be listed before inkstyle in CLAUDE_CODE_PLUGIN_DIRS (;-separated on Windows, : on Linux and macOS). The install commands above list it first.
Mods hook the same drawing in a chain: the first one loaded is the outermost, and it reaches the ones under it by handing the drawing on. prettier only wraps what is under it. inkstyle draws every reply and tool-call row itself (to colour them) and hands nothing on, so a mod loaded after inkstyle is never reached for those rows. Loaded first, prettier holds inkstyle's coloured replies and rows to 85% of the terminal; loaded after, they run the full width.
statusdeck's place in the list doesn't matter: it and prettier never draw the same thing.
In the wrong order nothing breaks; replies just go back to full width. Claude Code doesn't document the order mods load in; listing prettier first in this variable is what works. To check: ask Claude something with a long answer. If the reply wraps at about 85% of the terminal, the order is right.
The row under the prompt, with every colour in it:
⚡ and a countdown while the prompt cache is warm, ❄ once it has gone cold. The cache lifetime (1h on a Claude subscription, 5m with an API key) is a plugin option.statusdeck has no commands of its own. It doesn't draw the spinner; prettier does.
Colours only, inside Claude's replies and on tool-call rows:
| Command | What it does |
|---|---|
/inkstyle | on, off, or no argument to switch between them |
The layout of the chat. What it changes:
▸ 14 more lines. Beside the time, 14:32 ⧉ (same grey): a click on ⧉ copies that prompt's text and nothing else, and it reads a green ✓ for 3.5 seconds.python, or code when unlabeled) and ⧉ Copy at the right, which copies only the code and reads Copied! for 3.5 seconds. A panel is as wide as its longest line, never wider than the reply.+12 −3 (green and red) after an edit's row. While a reply is being written each diff shows whole at 75% of the terminal; once it is done, each edited or created file is one row, ▸ Edit hooks/app.ts +12 −3, that opens to its diff on a click.▸ Ran 2 commands · 1.2s. A click opens it: each call on a filled block with the whole command and its output (up to 40 lines). A click on any block, or on the row, closes it.✗ Bash failed, as wide as the error and no wider than the reply. Once the reply is done the call is one row, ▸ Edit app.ts · ✗ failed (✗ failed in red), that opens to its frame on a click. Declining a permission prompt is not a failure.⚙ Background task · completed · 2m 2s), not as your pill.39s · ▸ 2 tools, how long it took (dim) and how many tools it used (purple). A click on the count lists each call: ✓ Bash npm test · 1.2s.⠋ and Claude Code's word, or the tool running now: ⠋ ▸ running Bash….Everything compact toggles on a click: ▸ is closed, ▾ is open. Clicks only reach mods in Claude Code's fullscreen (alternate-screen) layout. On the normal screen these rows stay compact, edit diffs and errors stay whole (there would be no way to reopen them), the Copy buttons are hidden, and a folded prompt or message says ctrl+o. ctrl+o is Claude Code's own transcript view: it shows tool calls, outputs and long prompts in full; press it again to go back.
| Command | Settings (defaults) |
|---|---|
/prettier | lists the commands; on, off, reset (every setting back to its default) |
/prettier-prompt | width 70 (% of the terminal), lines 6 (shown before folding), time on |
/prettier-response | width 85 (% of the terminal; also the most a code panel or red frame takes), fold on (fold runs of reads, searches and commands) |
/prettier-code | width 75 (% of the terminal, for diffs) |
Each command on its own lists its settings and what they do. Change one or several at once: /prettier-prompt width 60 lines 4. Settings are kept across sessions. A wrong name or value says what's allowed.
│ lines may come out a little short.● <mod>: ... lines Claude Code prints when a mod loads, reloads or fails are Claude Code's own; no mod can restyle them.More detail is in mods/prettier/README.md.
Pull the latest changes and copy the mods again:
cd claude-code-utilities && git pull
cp -R mods/* ~/.claude/mods/
On Windows, run git pull and repeat the Copy-Item line from the install section. The environment variable only needs setting once.
types/ and tests/ folders inside each mod are for development and aren't needed to run the mods.hooks/register.tsx 697 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Cache, Clock, Controls, Run, Usage, Window } from '../types'
5
6const usageAtom = atom({ plugin: 'statusdeck', key: 'usage' } as const, {
7 fiveHour: null,
8 week: null,
9 context: null,
10} as Usage)
11const runAtom = atom({ plugin: 'statusdeck', key: 'run' } as const, {
12 isWorking: false,
13 startedAt: 0,
14 now: 0,
15 tokens: 0,
16} as Run)
17const controlsAtom = atom({ plugin: 'statusdeck', key: 'controls' } as const, {
18 effort: null,
19 model: null,
20} as Controls)
21const cacheAtom = atom({ plugin: 'statusdeck', key: 'cache' } as const, {
22 touchedAt: null,
23} as Cache)
24const clockAtom = atom({ plugin: 'statusdeck', key: 'clock' } as const, { now: 0 } as Clock)
25const panesSummary = { plugin: 'panes', key: 'summary' } as const
26
27// Palette shared with inkstyle and panes.
28const PILL_BG = '#202C34'
29const PILL_LABEL = '#D4DCE2'
30const PILL_TIMER = '#A7B3BD'
31const CACHE_TIME = '#A7B3BD'
32// The colour of a percentage used, blended channel by channel between stops.
33const USED_STOPS = [
34 { at: 0, color: '#8BD5F5' },
35 { at: 50, color: '#9FDFB5' },
36 { at: 70, color: '#FFD080' },
37 { at: 85, color: '#FF9E64' },
38 { at: 100, color: '#FF6B6B' },
39]
40// Blank cells between the parts of the status row.
41const MODE_GAP = 2
42const PILL_GAP = 1
43// The footer's left pad and a margin: a pill row this much narrower than the
44// terminal still fits on one line.
45const FOOTER_SLACK = 4
46const ORANGE = '#F97316'
47const PURPLE = '#A855F7'
48const PURPLE_LIGHT = '#C4B5FD'
49const GREEN = '#22C55E'
50const ICE = '#7DD3FC'
51const WARM_ICON = '⚡'
52const COLD_ICON = '❄'
53const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
54const TICK_MS = 160
55const POLL_MS = 1000
56const COUNT_MS = 3000
57const IDLE_CLOCK_MS = 30000
58
59// A coloured glyph and a short word, no background block: the mode at a glance
60// in a few cells.
61const MODES = [
62 { re: /plan mode/i, label: 'Plan', glyph: '◐', color: '#A78BFA' },
63 { re: /accept edits/i, label: 'Accept edits', glyph: '◉', color: '#4ADE80' },
64 { re: /bypass permissions/i, label: 'Bypass', glyph: '◆', color: '#F87171' },
65 { re: /auto mode/i, label: 'Auto', glyph: '◎', color: '#FBBF24' },
66 { re: /don'?t ask/i, label: "Don't ask", glyph: '◇', color: '#9CA3AF' },
67 { re: /manual mode/i, label: 'Manual', glyph: '○', color: '#94A3B8' },
68]
69// A mode this list does not know yet ("review mode on") still reads as one
70// word, in a neutral colour.
71function modeOf(text: string): (typeof MODES)[number] | undefined {
72 const known = MODES.find(m => m.re.test(text))
73 if (known !== undefined) return known
74 const name = /([a-z][a-z']*(?: [a-z][a-z']*)?) mode on/i.exec(text)?.[1]
75 if (name === undefined) return undefined
76 const label = name.charAt(0).toUpperCase() + name.slice(1).toLowerCase()
77 return { re: new RegExp(name, 'i'), label, glyph: '●', color: '#94A3B8' }
78}
79
80function hex(color: string): number[] {
81 const n = parseInt(color.slice(1), 16)
82 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
83}
84
85function mix(a: string, b: string, t: number): string {
86 const [ar = 0, ag = 0, ab = 0] = hex(a)
87 const [br = 0, bg = 0, bb = 0] = hex(b)
88 const part = (x: number, y: number) =>
89 Math.round(x + (y - x) * t)
90 .toString(16)
91 .padStart(2, '0')
92 return `#${part(ar, br)}${part(ag, bg)}${part(ab, bb)}`
93}
94
95function usedColor(percent: number): string {
96 const p = Math.min(100, Math.max(0, percent))
97 const i = USED_STOPS.findIndex(stop => stop.at >= p)
98 const hi = USED_STOPS[Math.max(1, i)]!
99 const lo = USED_STOPS[Math.max(1, i) - 1]!
100 return mix(lo.color, hi.color, (p - lo.at) / (hi.at - lo.at))
101}
102
103function countdown(resetsAt: string | null, now: number): string {
104 if (resetsAt === null) return ''
105 const ms = Date.parse(resetsAt) - now
106 if (!(ms > 0)) return ''
107 const minutes = Math.floor(ms / 60000)
108 const days = Math.floor(minutes / 1440)
109 const hours = Math.floor((minutes % 1440) / 60)
110 if (days > 0) return `${days}d${hours}h`
111 if (hours > 0) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
112 return `${minutes}m`
113}
114
115// Minutes while there is room to spare, m:ss for the last five.
116function warmth(ms: number): string {
117 if (ms >= 300000) return `${Math.ceil(ms / 60000)}m`
118 const s = Math.max(0, Math.ceil(ms / 1000))
119 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
120}
121
122function elapsed(ms: number): string {
123 const s = Math.max(0, Math.floor(ms / 1000))
124 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
125}
126
127function compact(n: number): string {
128 return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
129}
130
131function prettyModel(id: string | null): string {
132 if (id === null) return '…'
133 const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(id)
134 if (m === null) return id
135 const name = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1).toLowerCase()
136 return m[3] === undefined ? `${name} ${m[2]}` : `${name} ${m[2]}.${m[3]}`
137}
138
139function toWindow(limit: SessionRateLimit | undefined): Window | null {
140 return limit === undefined ? null : { percent: limit.percentUsed, resetsAt: limit.resetsAt ?? null }
141}
142
143// A window whose reset has passed holds nothing worth showing.
144function fresh(w: Window | null, now: number): Window | null {
145 if (w === null || w.resetsAt === null) return w
146 return Date.parse(w.resetsAt) > now ? w : null
147}
148
149// Before the first response of a session the engine has no reading yet
150// (rateLimits empty, no context fill), so a reading only replaces what it has.
151// While the window is counted as /context counts it, that count owns ctx: the
152// engine's figure is the last response's, stale after a /compact or /clear.
153function usageFrom(
154 prev: Usage,
155 context: { percent?: number } | undefined,
156 rateLimits: readonly SessionRateLimit[] | undefined,
157): Usage {
158 const limits = rateLimits ?? []
159 return {
160 fiveHour: toWindow(limits.find(l => l.kind === 'five_hour')) ?? prev.fiveHour,
161 week: toWindow(limits.find(l => l.kind === 'seven_day')) ?? prev.week,
162 context: isCounted ? prev.context : (context?.percent ?? prev.context),
163 }
164}
165
166// The rate-limit windows are the account's, so the last reading carries over
167// to the next session until a response brings a new one.
168async function saveUsage($: EngineInterface, usage: Usage): Promise<void> {
169 if (usage.fiveHour === null && usage.week === null) return
170 await $.store.set('limits', { fiveHour: usage.fiveHour, week: usage.week })
171}
172
173async function storedUsage($: EngineInterface, now: number): Promise<Usage> {
174 const held = (await $.store.get('limits')) as Partial<Usage> | undefined
175 return {
176 fiveHour: fresh(held?.fiveHour ?? null, now),
177 week: fresh(held?.week ?? null, now),
178 context: null,
179 }
180}
181
182async function refreshUsage($: EngineInterface): Promise<void> {
183 const { context, rateLimits } = await $.session.usage()
184 const prev = await read($, usageAtom)
185 const next = usageFrom(prev, context, rateLimits)
186 if (JSON.stringify(next) === JSON.stringify(prev)) return
187 await update($, usageAtom, () => next)
188 await saveUsage($, next)
189}
190
191// The window counted locally, as /context counts it (no request sent), so ctx
192// shows the figure /context shows: right after a /compact or /clear too, when
193// the engine has no fill until the next response. A count that fails leaves
194// ctx to the engine's figure.
195async function recountContext($: EngineInterface): Promise<void> {
196 lastCountAt = await $.clock.now()
197 const { context } = await $.session.usage({ breakdown: 'summary' })
198 const b = context.breakdown
199 const max = b?.rawMaxTokens ?? context.window
200 const percent =
201 b === undefined
202 ? null
203 : typeof b.percentage === 'number'
204 ? b.percentage
205 : max > 0
206 ? Math.round((b.totalTokens / max) * 100)
207 : null
208 isCounted = percent !== null
209 if (percent === null) return
210 if ((await read($, usageAtom)).context === percent) return
211 await update($, usageAtom, u => ({ ...u, context: percent }))
212}
213
214// "claude-opus-5-5[1m]", "claude-opus-5-5-20261001" and "opus" all name one model.
215function baseModel(id: string): string {
216 return id
217 .toLowerCase()
218 .replace(/\[.*?\]/g, '')
219 .replace(/-\d{8}$/, '')
220 .trim()
221}
222
223// This build keeps the effort per model, in settings.modelSettings[<model id>].
224function effortFor(settings: Record<string, unknown>, model: string): string | null {
225 const perModel = settings['modelSettings']
226 const want = baseModel(model)
227 if (want !== '' && perModel !== null && typeof perModel === 'object') {
228 const entries = Object.entries(perModel as Record<string, unknown>)
229 const exact = entries.filter(([name]) => baseModel(name) === want)
230 const loose = entries.filter(([name]) => {
231 const have = baseModel(name)
232 return have !== want && (have.includes(want) || want.includes(have))
233 })
234 for (const [, value] of [...exact, ...loose]) {
235 const level = (value as { effortLevel?: unknown } | null)?.effortLevel
236 if (typeof level === 'string') return level
237 }
238 }
239 const level = settings['effortLevel']
240 return typeof level === 'string' ? level : null
241}
242
243// One failing step (the engine not ready yet at launch) must not stop the rest.
244async function safely(work: () => Promise<unknown>): Promise<void> {
245 try {
246 await work()
247 } catch {
248 // The next poll tries again.
249 }
250}
251
252let ticker: Timer | undefined
253let poll: Timer | undefined
254let tokensDone = 0
255let ttlMs = 3600000
256// Whether the last local count of the window worked, and when it ran.
257let isCounted = false
258let lastCountAt = 0
259// What the settings said at the last poll; a poll only moves the label when
260// this changes, so a session-only /effort max is not flipped back.
261let seenKey = ''
262// Each model keeps a cache of its own: switching away and back (/model, or a
263// view whose model differs) brings back the one it left.
264const touchedByModel = new Map<string, number>()
265const pending = new Map<string, number>()
266
267function live(): number {
268 return tokensDone + [...pending.values()].reduce((a, b) => a + b, 0)
269}
270
271async function tick($: EngineInterface): Promise<void> {
272 const now = await $.clock.now()
273 const tokens = live()
274 await update($, runAtom, r => ({ ...r, now, tokens }))
275}
276
277function startTicker($: EngineInterface): void {
278 ticker?.cancel()
279 ticker = $.clock.every(TICK_MS, () => void tick($))
280}
281
282function stopTicker(): void {
283 ticker?.cancel()
284 ticker = undefined
285}
286
287// Writes only on a change, so the slow poll redraws nothing while idle.
288async function refreshControls($: EngineInterface, effort?: string): Promise<void> {
289 const model = await $.session.model()
290 const settings = (await $.settings.read()) as Record<string, unknown>
291 const level = effortFor(settings, model)
292 const key = `${model}|${level}`
293 const isChanged = key !== seenKey
294 // A miss (model or settings not ready at launch) is not remembered, so the
295 // next poll fills the label in rather than leaving "…".
296 if (level !== null && model !== '') seenKey = key
297 const current = await read($, controlsAtom)
298 const next = {
299 model: model !== '' ? model : current.model,
300 effort: effort ?? (level !== null && (isChanged || current.effort === null) ? level : current.effort),
301 }
302 // "claude-opus-5-5" and "claude-opus-5-5[1m]" share one cache.
303 const was = current.model === null ? '' : baseModel(current.model)
304 const now = next.model === null ? '' : baseModel(next.model)
305 if (was !== '' && now !== '' && was !== now) {
306 const { touchedAt } = await read($, cacheAtom)
307 if (touchedAt !== null) touchedByModel.set(was, touchedAt)
308 await update($, cacheAtom, () => ({ touchedAt: touchedByModel.get(now) ?? null }))
309 }
310 if (next.model === current.model && next.effort === current.effort) return
311 await update($, controlsAtom, () => next)
312}
313
314// Keeps the countdowns moving: every second while the cache is warm, else rarely.
315async function pulse($: EngineInterface): Promise<void> {
316 const now = await $.clock.now()
317 const { touchedAt } = await read($, cacheAtom)
318 const clock = await read($, clockAtom)
319 const isWarm = touchedAt !== null && touchedAt + ttlMs > now
320 const isJustCold = touchedAt !== null && !isWarm && clock.now < touchedAt + ttlMs
321 if (isWarm || isJustCold || now - clock.now >= IDLE_CLOCK_MS) {
322 await update($, clockAtom, () => ({ now }))
323 }
324 await safely(() => refreshControls($))
325 // The plain usage call is free and writes only on a change: catches the
326 // first reading of a session, and any measurement missed.
327 await safely(() => refreshUsage($))
328 // The local count every few seconds, so ctx never trails /context.
329 if (now - lastCountAt >= COUNT_MS) await safely(() => recountContext($))
330}
331
332function startPoll($: EngineInterface): void {
333 poll?.cancel()
334 poll = $.clock.every(POLL_MS, () => void safely(() => pulse($)))
335}
336
337async function pickEffort($: EngineInterface): Promise<void> {
338 try {
339 await $.command.run({ command: 'effort' })
340 } catch {
341 $.ui.toast('Could not open the effort picker; type /effort')
342 }
343 await refreshControls($)
344}
345
346// A chip opens that pane of the panes mod, beside the conversation or inline.
347async function openPane($: EngineInterface, id: string): Promise<void> {
348 try {
349 await $.command.run({ command: 'panes', args: id })
350 } catch {
351 $.ui.toast(`Could not open the pane; type /panes ${id}`)
352 }
353}
354
355async function pickModel($: EngineInterface): Promise<void> {
356 try {
357 await $.command.run({ command: 'model' })
358 } catch {
359 $.ui.toast('Could not open the model picker; type /model')
360 }
361 await refreshControls($)
362}
363
364export const register: Register = (on, options) => {
365 ttlMs = (options as Record<string, unknown>)['cacheTtl'] === '5m' ? 300000 : 3600000
366
367 // At launch the engine may not have the model, settings or a usage reading
368 // yet: the poll starts first and each step stands alone, so a miss here is
369 // filled in a second later instead of leaving the row blank.
370 on('session.start', async ($, e, next) => {
371 startPoll($)
372 await safely(async () => {
373 const started = await $.clock.now()
374 await update($, clockAtom, () => ({ now: started }))
375 const held = await storedUsage($, started)
376 await update($, usageAtom, u => ({
377 fiveHour: u.fiveHour ?? held.fiveHour,
378 week: u.week ?? held.week,
379 context: u.context,
380 }))
381 })
382 await safely(() => refreshUsage($))
383 await safely(() => refreshControls($))
384 await safely(async () => {
385 if ((await read($, runAtom)).isWorking) startTicker($)
386 })
387 return next(e)
388 })
389
390 on('session.measure', async ($, e, next) => {
391 const measured = usageFrom(await read($, usageAtom), e.context, e.rateLimits)
392 await update($, usageAtom, () => measured)
393 await safely(() => saveUsage($, measured))
394 // The live window's fill moves after each main-thread response: a request
395 // that went out over the cached prefix, so the cache was just refreshed.
396 if (e.changed.includes('context') && (e.context.tokens ?? 0) > 0) {
397 const now = await $.clock.now()
398 const { touchedAt } = await read($, cacheAtom)
399 if (touchedAt === null || now - touchedAt > 2000) {
400 await update($, cacheAtom, () => ({ touchedAt: now }))
401 }
402 await safely(() => recountContext($))
403 }
404 return next(e)
405 })
406
407 // A /clear starts an empty window with nothing cached.
408 on('session.end', async ($, e, next) => {
409 if (e.reason === 'clear') {
410 touchedByModel.clear()
411 await update($, cacheAtom, () => ({ touchedAt: null }))
412 await update($, usageAtom, u => ({ ...u, context: null }))
413 // The next poll counts the new window.
414 lastCountAt = 0
415 }
416 return next(e)
417 })
418
419 // The summarizer's request reads the cached system prompt and tools, which
420 // the next request reuses: the cache stays warm from that moment, and the
421 // window's fill drops to the summary's.
422 on('session.compact', async ($, e, next) => {
423 const done = await next(e)
424 if (e.agentId === undefined && done.skip === undefined && e.trigger !== 'precompute') {
425 const now = await $.clock.now()
426 await update($, cacheAtom, () => ({ touchedAt: now }))
427 await safely(() => recountContext($))
428 }
429 return done
430 })
431
432 on('turn.start', async ($, e, next) => {
433 if (e.agentId === undefined) {
434 tokensDone = 0
435 pending.clear()
436 const now = await $.clock.now()
437 await update($, runAtom, r => ({ ...r, isWorking: true, startedAt: now, now, tokens: 0 }))
438 startTicker($)
439 }
440 return next(e)
441 })
442
443 on('turn.complete', async ($, e, next) => {
444 if (e.agentId === undefined) {
445 stopTicker()
446 pending.clear()
447 await update($, runAtom, r => ({ ...r, isWorking: false }))
448 await safely(() => refreshControls($))
449 }
450 return next(e)
451 })
452
453 // Counts the turn's output tokens as they stream (an estimate while text
454 // arrives, the API's own count when a response ends), follows the effort the
455 // turn really runs at, and stamps the cache each main-thread request touches.
456 on('turn.step', async function* ($, e, next) {
457 const isMain = e.agentId === undefined
458 const startedAt = await $.clock.now()
459 if (isMain && typeof e.effort === 'string') {
460 const effort = e.effort
461 if ((await read($, controlsAtom)).effort !== effort) {
462 await update($, controlsAtom, c => ({ ...c, effort }))
463 }
464 }
465 const id = `${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
466 let chars = 0
467 let counted = false
468 let isStamped = false
469 const stamp = async (usage: { cache_read_input_tokens: number; cache_creation_input_tokens: number } | null) => {
470 if (!isMain || isStamped || usage === null) return
471 if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens <= 0) return
472 isStamped = true
473 await update($, cacheAtom, () => ({ touchedAt: startedAt }))
474 }
475 const stream = next(e)
476 try {
477 let step = await stream.next()
478 while (step.done !== true) {
479 const chunk = step.value
480 if (chunk.kind === 'text' || chunk.kind === 'thinking') chars += chunk.text.length
481 else if (chunk.kind === 'input') chars += chunk.json.length
482 else if (chunk.kind === 'stop' && chunk.usage !== null) {
483 tokensDone += chunk.usage.output_tokens
484 counted = true
485 await stamp(chunk.usage)
486 }
487 pending.set(id, counted ? 0 : Math.round(chars / 4))
488 yield chunk
489 step = await stream.next()
490 }
491 const result = step.value
492 if (result !== undefined) await stamp(result.usage)
493 return result
494 } finally {
495 if (!counted) tokensDone += Math.round(chars / 4)
496 pending.delete(id)
497 }
498 })
499
500 on('command.run', { command: 'effort' }, async ($, e, next) => {
501 const ran = await next(e)
502 const typed = e.args.trim().toLowerCase()
503 await refreshControls($, EFFORTS.includes(typed) ? typed : undefined)
504 return ran
505 }).catch(($, e, next) => next(e))
506
507 on('command.run', { command: 'model' }, async ($, e, next) => {
508 const ran = await next(e)
509 await refreshControls($)
510 return ran
511 }).catch(($, e, next) => next(e))
512
513 // The band above the prompt: only the panes chips, so without them it draws
514 // nothing and takes no row.
515 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
516 if (e.props.hasSurvey) return next(e)
517
518 const { Box, Button, Text } = $.ui.resolve(e)
519 const { value: panes } = await $.state.get(panesSummary)
520
521 // While the panes panel is off screen, its news folds into one quiet row.
522 const chips = (p: NonNullable<typeof panes>) => {
523 const items = []
524 if (p.branch !== null) {
525 items.push(
526 <Button key="chip-git" plain onPress={() => openPane($, 'git')}>
527 <Text color={PURPLE} bold>{`⎇ ${p.branch}`}</Text>
528 {p.ahead > 0 && <Text color={PURPLE_LIGHT}>{` ↑${p.ahead}`}</Text>}
529 {p.behind > 0 && <Text color={ORANGE}>{` ↓${p.behind}`}</Text>}
530 {p.changed > 0 && <Text dimColor>{` · ${p.changed} changed`}</Text>}
531 </Button>,
532 )
533 }
534 if (p.edited > 0) {
535 items.push(
536 <Button key="chip-files" plain onPress={() => openPane($, 'files')}>
537 <Text color={ORANGE}>◆</Text>
538 <Text>{` ${p.edited} edited`}</Text>
539 </Button>,
540 )
541 }
542 if (p.total > 0) {
543 items.push(
544 <Button key="chip-tasks" plain onPress={() => openPane($, 'tasks')}>
545 <Text color={GREEN}>✓</Text>
546 <Text>{` ${p.done}/${p.total} tasks`}</Text>
547 </Button>,
548 )
549 }
550 if (items.length === 0) return null
551 return (
552 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
553 {items}
554 </Box>
555 )
556 }
557
558 const chipRow = panes !== undefined && !panes.isPanelShown ? chips(panes) : null
559 return chipRow ?? next(e)
560 })
561
562 // The footer row under the prompt is the engine's: its own mode text, then
563 // " · " and the hint at the left, the SessionMode slot at the far right,
564 // which it moves to a row of its own, still flush right, when the two do not
565 // fit side by side. So the hint draws the mode, model and effort, the slot
566 // draws the pills and the cache, and neither asks for more width than its
567 // words: a part that did would squeeze the engine's mode text until it ran
568 // into the model ("auto mode onOpus") and its " ·" fell to a row of its own.
569
570 // The 5h, 7d and ctx pills and the cache time. None of the engine's mode
571 // labels draw, nor the " · " it sets before them.
572 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
573 const { Box, Text } = $.ui.resolve(e)
574 // A blank of fixed width that never shrinks, so on a narrow terminal the
575 // words keep their spacing and wrap whole instead of running together.
576 const gap = (key: string, cells: number) => <Box key={key} width={cells} flexShrink={0} />
577 const usage = await read($, usageAtom)
578 const cache = await read($, cacheAtom)
579 const clock = await read($, clockAtom)
580 const now = clock.now > 0 ? clock.now : await $.clock.now()
581
582 const remaining = cache.touchedAt === null ? 0 : cache.touchedAt + ttlMs - now
583 const isWarm = remaining > 0
584 const cacheText = isWarm ? warmth(remaining) : 'cold'
585 const percentText = (p: number | null) => (p === null ? '–' : `${Math.round(p)}%`)
586
587 const pills = [
588 usage.fiveHour === null
589 ? null
590 : { key: '5h', name: '5h', percent: usage.fiveHour.percent, reset: countdown(usage.fiveHour.resetsAt, now) },
591 usage.week === null
592 ? null
593 : { key: '7d', name: '7d', percent: usage.week.percent, reset: countdown(usage.week.resetsAt, now) },
594 { key: 'ctx', name: 'ctx', percent: usage.context, reset: '' },
595 ].filter(p => p !== null)
596
597 // Each pill sets its own background on every piece, so nothing bleeds past
598 // its closing pad.
599 const pill = (p: (typeof pills)[number]) => (
600 <Box key={p.key} flexDirection="row" flexShrink={0}>
601 <Text backgroundColor={PILL_BG} color={PILL_LABEL} bold>
602 {` ${p.name} `}
603 </Text>
604 <Text backgroundColor={PILL_BG} color={p.percent === null ? PILL_TIMER : usedColor(p.percent)} bold>
605 {percentText(p.percent)}
606 </Text>
607 <Text backgroundColor={PILL_BG} color={PILL_TIMER}>
608 {p.reset === '' ? ' ' : ` ${p.reset} `}
609 </Text>
610 </Box>
611 )
612
613 const parts = [
614 ...pills.map(p => (
615 <Box key={`gap-${p.key}`} flexDirection="row" flexShrink={0}>
616 {pill(p)}
617 {gap(`gap-after-${p.key}`, PILL_GAP)}
618 </Box>
619 )),
620 <Box key="cache" flexDirection="row" flexShrink={0}>
621 <Text color={isWarm ? ORANGE : ICE}>{isWarm ? WARM_ICON : COLD_ICON}</Text>
622 <Text color={CACHE_TIME}>{` ${cacheText}`}</Text>
623 </Box>,
624 ]
625
626 // Cells the group takes on one line (⚡ is two wide, ❄ one).
627 const cells =
628 pills.reduce(
629 (n, p) => n + p.name.length + 2 + percentText(p.percent).length + (p.reset === '' ? 1 : p.reset.length + 2) + PILL_GAP,
630 0,
631 ) +
632 (isWarm ? 2 : 1) +
633 1 +
634 cacheText.length
635 // A row of exactly its words while it fits the terminal; only narrower
636 // than that does it wrap, flush right, on the row the engine gives it.
637 const isOneLine = cells + FOOTER_SLACK <= (e.viewport?.columns ?? 80)
638 return isOneLine ? (
639 <Box key="right" flexDirection="row" flexShrink={0}>
640 {parts}
641 </Box>
642 ) : (
643 <Box key="right" flexDirection="row" flexWrap="wrap" justifyContent="flex-end">
644 {parts}
645 </Box>
646 )
647 })
648
649 // The mode, model and effort, after the engine's own mode text; while a turn
650 // runs, its time and live output tokens follow the effort.
651 on('ui.render', { component: 'PromptHint' }, async ($, e) => {
652 const { Box, Button, Text } = $.ui.resolve(e)
653 // A blank of fixed width that never shrinks, so on a narrow terminal the
654 // words keep their spacing and wrap whole instead of running together.
655 const gap = (key: string, cells: number) => <Box key={key} width={cells} flexShrink={0} />
656 const controls = await read($, controlsAtom)
657 const run = await read($, runAtom)
658 const mode = modeOf(e.props.hint)
659 const model = prettyModel(controls.model)
660 const effort = controls.effort ?? '…'
661
662 return (
663 <Box key="left" flexDirection="row" flexShrink={0}>
664 {mode !== undefined && (
665 <Box key="mode" flexShrink={0}>
666 <Text color={mode.color} bold>
667 {`${mode.glyph} ${mode.label}`}
668 </Text>
669 </Box>
670 )}
671 {mode !== undefined && gap('gap-mode', MODE_GAP)}
672 <Box key="model-effort" flexDirection="row" flexShrink={0}>
673 <Button key="model" plain onPress={() => pickModel($)}>
674 <Text color={ORANGE} bold>
675 {model}
676 </Text>
677 </Button>
678 {gap('gap-effort', 1)}
679 <Button key="effort" plain onPress={() => pickEffort($)}>
680 <Text color={ORANGE} bold>
681 {effort}
682 </Text>
683 </Button>
684 </Box>
685 {run.isWorking && gap('gap-run', MODE_GAP)}
686 {run.isWorking && (
687 <Box key="run" flexDirection="row" flexShrink={0}>
688 <Text color={PURPLE_LIGHT}>{`⏱ ${elapsed(run.now - run.startedAt)}`}</Text>
689 {gap('gap-tokens', 1)}
690 <Text color={PURPLE_LIGHT}>{`↓ ${compact(run.tokens)} tok`}</Text>
691 </Box>
692 )}
693 </Box>
694 )
695 })
696}
697types/index.d.ts 46 lines1export type Window = { percent: number; resetsAt: string | null }
2
3export type Usage = {
4 fiveHour: Window | null
5 week: Window | null
6 context: number | null
7}
8
9export type Run = {
10 isWorking: boolean
11 startedAt: number
12 now: number
13 tokens: number
14}
15
16export type Controls = {
17 effort: string | null
18 model: string | null
19}
20
21// When the main thread's prompt cache was last read or written (ms), or null
22// when it is cold or unknown.
23export type Cache = { touchedAt: number | null }
24
25// A coarse clock the band reads, so countdowns redraw without the turn ticker.
26export type Clock = { now: number }
27
28// What the panes mod publishes for the chips (its own contract owns it).
29export type PanesSummary = {
30 isPanelShown: boolean
31 branch: string | null
32 ahead: number
33 behind: number
34 changed: number
35 edited: number
36 done: number
37 total: number
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 statusdeck: { usage: Usage; run: Run; controls: Controls; cache: Cache; clock: Clock }
43 panes: { summary: PanesSummary }
44 }
45}
46