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

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.
hooks/register.ts 83 lines1import 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