SLOPSHOPPER

statusdeck

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

newbandspinnertoasttimer
v0.4.4no licenseupdated 2026-10-16MoizAsif616/claude-code-utilities/mods/statusdeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusdeck
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › modeleffort 5h 31% ctx 49% ⚡ 61m

Draws

Prompt hint
modeleffort
README

claude-code-utilities

Mods for Claude Code. Each mod has one job and works on its own: none imports, reads or calls another.

ModIts job
mods/statusdeckThe status row under the prompt: live stats and their colours
mods/inkstyleColours inside Claude's replies and on tool-call rows
mods/prettierThe 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.

Install

Windows (PowerShell)

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')

Linux (Ubuntu)

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.

macOS

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.

Load order: prettier goes first

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.

statusdeck

The row under the prompt, with every colour in it:

  • Mode, model and effort at the left. A click on the model or the effort opens Claude Code's picker for it.
  • Usage pills at the right: the 5-hour and 7-day rate-limit windows and the context fill, each coloured from blue (little used) through green and amber to red (nearly full), with the time until a window resets.
  • Cache warmth: ⚡ 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.
  • While Claude works: the turn's running time and live output tokens after the effort.
  • Pane chips above the prompt when the panes mod is installed.

statusdeck has no commands of its own. It doesn't draw the spinner; prettier does.

inkstyle

Colours only, inside Claude's replies and on tool-call rows:

  • Replies: orange headings and bold, lavender links, purple list and quote markers, and tables with orange headers and striped rows.
  • Tool-call rows: a status dot (purple when done, light purple while running, red when it failed, grey when interrupted), the tool's name in purple and what it worked on in grey. The command or path is shown whole, never cut.
CommandWhat it does
/inkstyleon, off, or no argument to switch between them

prettier

The layout of the chat. What it changes:

  • Your prompts: a pill with a rounded purple border on the right, one space of padding on the left, at most 70% of the terminal wide, with the time you sent it under it. A long prompt shows its first 6 lines, then ▸ 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.
  • Replies and tool-call rows: held to the left 85% of the terminal (needs the load order).
  • Code blocks in replies: each fenced block is a charcoal panel with a thin violet rounded border. Its top row holds the language on a lighter chip at the left (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.
  • Edits: +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.
  • Reads, searches and commands: a run of them folds to one row, ▸ 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.
  • Failures: a failed tool's error in a red frame, ✗ 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.
  • Rows you didn't type: background tasks finishing and messages from other agents, sessions or channels show as a quiet card on the left (⚙ Background task · completed · 2m 2s), not as your pill.
  • End of a reply: 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.
  • Spinner: drawn whole by prettier, a purple animated ⠋ and Claude Code's word, or the tool running now: ⠋ ▸ running Bash….

Opening folded rows

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.

Commands

CommandSettings (defaults)
/prettierlists the commands; on, off, reset (every setting back to its default)
/prettier-promptwidth 70 (% of the terminal), lines 6 (shown before folding), time on
/prettier-responsewidth 85 (% of the terminal; also the most a code panel or red frame takes), fold on (fold runs of reads, searches and commands)
/prettier-codewidth 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.

Known limits

  • Tool output and progress text that Claude Code draws itself run the full terminal width: Claude Code refuses a mod that puts its own drawing inside a fixed width.
  • inkstyle sizes tables for the full terminal width; inside the 85% box their columns are squeezed, so a wide table's │ lines may come out a little short.
  • Times and tool lists only appear for prompts and replies made after the mod loaded, not for resumed sessions.
  • The dim ● <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.

Update

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.

Notes

  • The types/ and tests/ folders inside each mod are for development and aren't needed to run the mods.
  • Tested on Windows. The code has nothing Windows-specific, so it should work on Linux and macOS, but I haven't verified that.
Source 2 files
hooks/register.tsx 697 lines
1import { 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}
697
types/index.d.ts 46 lines
1export 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