SLOPSHOPPER

statusline

Context tokens, session cost, and estimated cache expiry above the prompt in the Desktop app

newbandtimer
★ 20v0.1.0MITupdated 2026-10-02david-crespo/dotfiles/claude/mods/statusline
A shopper browsing a rack in a slop shop
README

dotfiles

install.sh is a good guide to how things work because it sets up all the symlinks.

Note: .helix and deno.jsonc are not dotfiles; they are config files for this repo.

Source 1 files
hooks/register.ts 83 lines
1import type { On } from 'claude-code'
2
3// The Desktop app doesn't draw the statusLine command (../../statusline.ts),
4// so this draws the same tokens | cost | cache line in the band above
5// the prompt there. The terminal keeps using statusline.ts, so nothing is
6// drawn there.
7
8const kFmt = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 })
9const costFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
10
11// statusline.ts gets prompt_cache.expires_at from Claude Code, but the mods API
12// doesn't expose it, so estimate it from the last main-loop request. Every
13// request refreshes the cache's TTL. The engine knows the TTL (5m or 1h) but
14// only hands it to hooks on a model switch, so assume 1h, which is what this
15// account gets outside usage overage. Show the time a few minutes early so the
16// estimate errs toward cold.
17const CACHE_TTL_MS = 60 * 60 * 1000
18const CACHE_MARGIN_MS = 3 * 60 * 1000
19
20let lastRequestAt: number | undefined
21
22function cacheColdSegment(now: number): string | undefined {
23  if (lastRequestAt === undefined) return undefined
24  const coldAt = lastRequestAt + CACHE_TTL_MS - CACHE_MARGIN_MS
25  if (now >= coldAt) return 'cache cold'
26  const time = new Date(coldAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
27  return `cache cold ${time}`
28}
29
30export function register(on: On) {
31  // Nothing else redraws the band when the cache estimate passes
32  on('session.start', async ($, e, next) => {
33    $.clock.every(60 * 1000, () => $.ui.invalidate('ui.render'))
34    return next(e)
35  })
36
37  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
38    if (e.surface !== 'desktop') return next(e)
39    const { Box, Text } = $.ui.resolve(e)
40    // The Desktop app shows the model itself, under the prompt
41    const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()])
42    // tokens is absent until the first response reports it
43    const tokens = usage.context.tokens ?? 0
44    const pct = usage.context.percent ?? Math.floor((tokens * 100) / usage.context.window)
45    const segments = [
46      `${kFmt.format(tokens / 1000)}k (${pct}%)`,
47      costFmt.format(usage.cost?.usd ?? 0),
48      cacheColdSegment(now),
49    ].filter(Boolean)
50    // Keep whatever later mods draw in the band
51    return Box({
52      flexDirection: 'column',
53      children: [await next(e), Text({ dimColor: true, children: [segments.join(' | ')] })],
54    })
55  })
56
57  on('turn.step', async function* ($, e, next) {
58    const result = yield* next(e)
59    // Subagents have their own cache prefixes, and a request with no usage
60    // never reached the API
61    if (e.agentId === undefined && result.usage) {
62      lastRequestAt = await $.clock.now()
63    }
64    return result
65  })
66
67  // A resumed session's cache is as old as its last response, and a cleared
68  // one hasn't sent anything yet
69  on('classic.SessionStart', { source: ['resume', 'fork', 'clear'] }, async ($, e, next) => {
70    lastRequestAt =
71      e.seconds_since_last_response === undefined
72        ? undefined
73        : (await $.clock.now()) - e.seconds_since_last_response * 1000
74    return next(e)
75  })
76
77  // Usage changes after each request, not on any render-site prop change
78  on('session.measure', async ($, e, next) => {
79    $.ui.invalidate('ui.render')
80    return next(e)
81  })
82}
83